diff --git a/docs/_ext/fastmcp_autodoc.py b/docs/_ext/fastmcp_autodoc.py index a21f185..7bdbc69 100644 --- a/docs/_ext/fastmcp_autodoc.py +++ b/docs/_ext/fastmcp_autodoc.py @@ -65,18 +65,35 @@ TAG_DESTRUCTIVE = "destructive" _MODEL_MODULE = "libtmux_mcp.models" -_MODEL_CLASSES: set[str] = { - "SessionInfo", - "WindowInfo", - "PaneInfo", - "PaneContentMatch", - "ServerInfo", - "OptionResult", - "OptionSetResult", - "EnvironmentResult", - "EnvironmentSetResult", - "WaitForTextResult", -} +_model_classes_cache: set[str] | None = None + + +def _discover_model_classes() -> set[str]: + """Discover all BaseModel subclasses in libtmux_mcp.models. + + Results are cached after first call. Only discovers classes whose + ``__module__`` matches ``_MODEL_MODULE`` to prevent third-party leakage. + """ + global _model_classes_cache + if _model_classes_cache is not None: + return _model_classes_cache + import inspect as _inspect + + from pydantic import BaseModel + + try: + mod = importlib.import_module(_MODEL_MODULE) + except ImportError: + logger.warning("fastmcp_autodoc: could not import %s", _MODEL_MODULE) + _model_classes_cache = set() + return _model_classes_cache + _model_classes_cache = { + name + for name, obj in _inspect.getmembers(mod, _inspect.isclass) + if issubclass(obj, BaseModel) + and getattr(obj, "__module__", "") == _MODEL_MODULE + } + return _model_classes_cache # --------------------------------------------------------------------------- @@ -111,6 +128,40 @@ class ToolInfo: return_annotation: str +@dataclass +class ResourceInfo: + """Collected metadata for a single MCP resource.""" + + name: str + qualified_name: str + title: str + uri_template: str + docstring: str + params: list[ParamInfo] + return_annotation: str + + +@dataclass +class ModelFieldInfo: + """Extracted field information for a Pydantic model.""" + + name: str + type_str: str + required: bool + default: str + description: str + + +@dataclass +class ModelInfo: + """Collected metadata for a single Pydantic model.""" + + name: str + qualified_name: str + docstring: str + fields: list[ModelFieldInfo] + + # --------------------------------------------------------------------------- # Docstring + signature parsing # --------------------------------------------------------------------------- @@ -302,7 +353,7 @@ def _single_type_xref(name: str) -> addnodes.pending_xref: Known model classes are qualified to ``libtmux_mcp.models.X``. Builtins (``str``, ``list``, ``int``, etc.) target the Python domain. """ - target = f"{_MODEL_MODULE}.{name}" if name in _MODEL_CLASSES else name + target = f"{_MODEL_MODULE}.{name}" if name in _discover_model_classes() else name return addnodes.pending_xref( "", nodes.literal("", name), @@ -459,6 +510,76 @@ def _safety_badge(safety: str) -> _safety_badge_node: return badge +class _resource_badge_node(nodes.General, nodes.Inline, nodes.Element): # type: ignore[misc] + """Custom node for resource badges with ARIA attributes in HTML output.""" + + +def _visit_resource_badge_html(self: t.Any, node: _resource_badge_node) -> None: + """Emit opening ```` with classes, role, and aria-label.""" + classes = " ".join(node.get("classes", [])) + self.body.append( + f'' + ) + + +def _depart_resource_badge_html(self: t.Any, node: _resource_badge_node) -> None: + """Close the ````.""" + self.body.append("") + + +def _resource_badge() -> _resource_badge_node: + """Create a blue resource badge node with ARIA attributes.""" + _base = ["sd-sphinx-override", "sd-badge"] + badge = _resource_badge_node( + "", + nodes.Text("resource"), + classes=[*_base, "sd-bg-info", "sd-bg-text-info"], + ) + return badge + + +class _model_badge_node(nodes.General, nodes.Inline, nodes.Element): # type: ignore[misc] + """Custom node for model badges with ARIA attributes in HTML output.""" + + +def _visit_model_badge_html(self: t.Any, node: _model_badge_node) -> None: + """Emit opening ```` with classes, role, and aria-label.""" + classes = " ".join(node.get("classes", [])) + self.body.append(f'') + + +def _depart_model_badge_html(self: t.Any, node: _model_badge_node) -> None: + """Close the ````.""" + self.body.append("") + + +def _model_badge() -> _model_badge_node: + """Create a purple model badge node with ARIA attributes.""" + _base = ["sd-sphinx-override", "sd-badge"] + badge = _model_badge_node( + "", + nodes.Text("model"), + classes=[*_base, "sd-bg-primary", "sd-bg-text-primary"], + ) + return badge + + +class _resource_ref_placeholder(nodes.General, nodes.Inline, nodes.Element): # type: ignore[misc] + """Placeholder node for ``{resource}`` and ``{resourceref}`` roles. + + Resolved at ``doctree-resolved`` by ``_resolve_resource_refs``. + The ``show_badge`` attribute controls whether the resource badge is appended. + """ + + +class _model_ref_placeholder(nodes.General, nodes.Inline, nodes.Element): # type: ignore[misc] + """Placeholder node for ``{model}`` and ``{modelref}`` roles. + + Resolved at ``doctree-resolved`` by ``_resolve_model_refs``. + The ``show_badge`` attribute controls whether the model badge is appended. + """ + + # --------------------------------------------------------------------------- # Tool collection (runs at builder-inited) # --------------------------------------------------------------------------- @@ -512,6 +633,38 @@ def decorator(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: return decorator +class _ResourceCollector: + """Mock FastMCP that captures resource registrations.""" + + def __init__(self) -> None: + self.resources: list[ResourceInfo] = [] + self._current_module: str = "" + + def resource( + self, + uri_template: str, + title: str = "", + **kwargs: t.Any, + ) -> t.Callable[[t.Callable[..., t.Any]], t.Callable[..., t.Any]]: + def decorator(func: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: + self.resources.append( + ResourceInfo( + name=func.__name__, + qualified_name=f"{self._current_module}.{func.__name__}", + title=title or func.__name__.replace("_", " ").title(), + uri_template=uri_template, + docstring=func.__doc__ or "", + params=_extract_params(func), + return_annotation=_format_annotation( + inspect.signature(func).return_annotation, + ), + ) + ) + return func + + return decorator + + def _collect_tools(app: Sphinx) -> None: """Collect tool metadata from libtmux_mcp source at build time.""" collector = _ToolCollector() @@ -541,6 +694,111 @@ def _collect_tools(app: Sphinx) -> None: app.env.fastmcp_tools = {tool.name: tool for tool in collector.tools} # type: ignore[attr-defined] +def _collect_resources(app: Sphinx) -> None: + """Collect resource metadata from libtmux_mcp source at build time.""" + collector = _ResourceCollector() + + resource_modules = ["hierarchy"] + + for mod_name in resource_modules: + collector._current_module = mod_name + try: + mod = importlib.import_module(f"libtmux_mcp.resources.{mod_name}") + if hasattr(mod, "register"): + mod.register(collector) + except Exception: + logger.warning( + "fastmcp_autodoc: failed to load resource module %s", + mod_name, + exc_info=True, + ) + + app.env.fastmcp_resources = {r.name: r for r in collector.resources} # type: ignore[attr-defined] + + +def _collect_models(app: Sphinx) -> None: + """Collect Pydantic model metadata from libtmux_mcp.models at build time.""" + from pydantic import BaseModel + + try: + mod = importlib.import_module(_MODEL_MODULE) + except ImportError: + logger.warning("fastmcp_autodoc: could not import %s", _MODEL_MODULE) + app.env.fastmcp_models = {} # type: ignore[attr-defined] + return + + models: dict[str, ModelInfo] = {} + for name, obj in inspect.getmembers(mod, inspect.isclass): + if not issubclass(obj, BaseModel): + continue + if getattr(obj, "__module__", "") != _MODEL_MODULE: + continue + + fields: list[ModelFieldInfo] = [] + for field_name, field_info in obj.model_fields.items(): + # Determine type string + ann = obj.__annotations__.get(field_name, "") + type_str = _format_annotation(ann) + + # Determine required / default + has_default_factory = ( + hasattr(field_info, "default_factory") + and field_info.default_factory is not None + ) + has_default = not field_info.is_required() and not has_default_factory + + if has_default_factory: + required = False + factory = field_info.default_factory + # Show factory name for common factories + default_str = f"{factory.__name__}()" if factory else "" + elif has_default: + required = False + default_val = field_info.default + if default_val is None: + default_str = "None" + elif isinstance(default_val, bool): + default_str = str(default_val) + elif isinstance(default_val, str): + default_str = repr(default_val) + else: + default_str = str(default_val) + else: + required = True + default_str = "" + + # Extract description from Field(description=...) + description = "" + if hasattr(field_info, "description") and field_info.description: + description = field_info.description + + fields.append( + ModelFieldInfo( + name=field_name, + type_str=type_str, + required=required, + default=default_str, + description=description, + ) + ) + + models[name] = ModelInfo( + name=name, + qualified_name=f"{_MODEL_MODULE}.{name}", + docstring=obj.__doc__ or "", + fields=fields, + ) + + app.env.fastmcp_models = models # type: ignore[attr-defined] + + +def _collect_all(app: Sphinx) -> None: + """Collect tools, resources, and models at build time.""" + _collect_tools(app) + _collect_resources(app) + _collect_models(app) + + # --------------------------------------------------------------------------- # Directives # --------------------------------------------------------------------------- @@ -773,9 +1031,9 @@ def run(self) -> list[nodes.Node]: rows: list[list[str | nodes.Node]] = [] for tool in sorted(tier_tools, key=lambda t: t.name): first_line = _first_paragraph(tool.docstring) - # Link to the tool's section on its area page + section_id = tool.name.replace("_", "-") ref = nodes.reference("", "", internal=True) - ref["refuri"] = f"{tool.area}/#{tool.name.replace('_', '-')}" + ref["refuri"] = f"#{section_id}" ref += nodes.literal("", tool.name) rows.append( [ @@ -790,6 +1048,410 @@ def run(self) -> list[nodes.Node]: return result_nodes +class FastMCPResourceDirective(SphinxDirective): + """Autodocument a single MCP resource as a proper section. + + Creates a section node (visible in ToC) containing: + - Resource badge + one-line description + - URI template literal block + - Optional parameter table + - Return type + + Usage:: + + ```{fastmcp-resource} hierarchy.get_sessions + ``` + """ + + required_arguments = 1 + optional_arguments = 0 + has_content = True + final_argument_whitespace = False + + def run(self) -> list[nodes.Node]: + """Build resource section nodes.""" + arg = self.arguments[0] + func_name = arg.split(".")[-1] if "." in arg else arg + + resources: dict[str, ResourceInfo] = getattr(self.env, "fastmcp_resources", {}) + resource = resources.get(func_name) + + if resource is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-resource: resource '{func_name}' not found. " + f"Available: {', '.join(sorted(resources.keys()))}", + line=self.lineno, + ) + ] + + return self._build_resource_section(resource) + + def _build_resource_section(self, resource: ResourceInfo) -> list[nodes.Node]: + """Build section: title, badge, description, URI template, params.""" + document = self.state.document + + # Section with anchor ID + section_id = f"resource-{resource.name.replace('_', '-')}" + section = nodes.section() + section["ids"].append(section_id) + document.note_explicit_target(section) + + # Title: resource name + resource badge + title_node = nodes.title("", "") + title_node += nodes.literal("", resource.name) + title_node += nodes.Text(" ") + title_node += _resource_badge() + section += title_node + + # Description paragraph + first_para = _first_paragraph(resource.docstring) + if first_para: + desc_para = _parse_rst_inline(first_para, self.state, self.lineno) + section += desc_para + + # URI template as literal block + uri_block = nodes.literal_block("", resource.uri_template) + uri_block["language"] = "none" + uri_block["classes"].append("fastmcp-uri-template") + section += uri_block + + # Returns + if resource.return_annotation: + returns_para = nodes.paragraph("") + returns_para += nodes.strong("", "Returns: ") + type_para = _make_type_xref(resource.return_annotation) + for child in type_para.children: + returns_para += child.deepcopy() + section += returns_para + + # Parameter table + if resource.params: + section += _make_para(nodes.strong("", "Parameters")) + headers = ["Parameter", "Type", "Required", "Default", "Description"] + rows: list[list[str | nodes.Node]] = [] + for p in resource.params: + desc_node = ( + _parse_rst_inline(p.description, self.state, self.lineno) + if p.description + else nodes.paragraph("", "\u2014") + ) + + type_cell, _is_enum = _make_type_cell_smart(p.type_str) + + default_cell: str | nodes.Node = "\u2014" + if p.default and p.default != "None": + default_cell = _make_para(_make_literal(p.default)) + + rows.append( + [ + _make_para(_make_literal(p.name)), + type_cell, + "yes" if p.required else "no", + default_cell, + desc_node, + ] + ) + section += _make_table(headers, rows, col_widths=[15, 15, 8, 10, 52]) + + return [section] + + +class FastMCPResourceSummaryDirective(SphinxDirective): + """Generate a summary table of all resources. + + Produces a single table with URI Template, Title, and Description columns. + + Usage:: + + ```{fastmcp-resourcesummary} + ``` + """ + + required_arguments = 0 + optional_arguments = 0 + has_content = False + + def run(self) -> list[nodes.Node]: + """Build resource summary table.""" + resources: dict[str, ResourceInfo] = getattr(self.env, "fastmcp_resources", {}) + + if not resources: + return [ + self.state.document.reporter.warning( + "fastmcp-resourcesummary: no resources found.", + line=self.lineno, + ) + ] + + headers = ["URI Template", "Title", "Description"] + rows: list[list[str | nodes.Node]] = [] + for resource in sorted(resources.values(), key=lambda r: r.uri_template): + first_line = _first_paragraph(resource.docstring) + ref = nodes.reference("", "", internal=True) + section_id = f"resource-{resource.name.replace('_', '-')}" + ref["refuri"] = f"#{section_id}" + ref += nodes.literal("", resource.uri_template) + rows.append( + [ + _make_para(ref), + resource.title, + _parse_rst_inline(first_line, self.state, self.lineno), + ] + ) + + return [_make_table(headers, rows, col_widths=[35, 15, 50])] + + +class FastMCPModelDirective(SphinxDirective): + """Autodocument a single Pydantic model as a proper section. + + Creates a section node (visible in ToC) containing: + - Model badge + docstring + - Field table (Field, Type, Required, Default, Description) + + Options: + - ``:fields:`` — comma-separated allowlist of fields to include + - ``:exclude:`` — comma-separated denylist of fields to exclude + + Usage:: + + ```{fastmcp-model} SessionInfo + ``` + """ + + required_arguments = 1 + optional_arguments = 0 + has_content = True + final_argument_whitespace = False + option_spec: t.ClassVar[dict[str, t.Any]] = { + "fields": lambda x: x, + "exclude": lambda x: x, + } + + def run(self) -> list[nodes.Node]: + """Build model section nodes.""" + model_name = self.arguments[0].strip() + + models: dict[str, ModelInfo] = getattr(self.env, "fastmcp_models", {}) + model = models.get(model_name) + + if model is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-model: model '{model_name}' not found. " + f"Available: {', '.join(sorted(models.keys()))}", + line=self.lineno, + ) + ] + + return self._build_model_section(model) + + def _build_model_section(self, model: ModelInfo) -> list[nodes.Node]: + """Build section: title, badge, docstring, field table.""" + document = self.state.document + + # Section with anchor ID + section_id = f"model-{model.name}" + section = nodes.section() + section["ids"].append(section_id) + document.note_explicit_target(section) + + # Title: model name + model badge + title_node = nodes.title("", "") + title_node += nodes.literal("", model.name) + title_node += nodes.Text(" ") + title_node += _model_badge() + section += title_node + + # Docstring + first_para = _first_paragraph(model.docstring) + if first_para: + desc_para = _parse_rst_inline(first_para, self.state, self.lineno) + section += desc_para + + # Field table + fields = self._filter_fields(model.fields) + if fields: + section += self._build_field_table(fields) + + return [section] + + def _filter_fields(self, fields: list[ModelFieldInfo]) -> list[ModelFieldInfo]: + """Apply :fields: and :exclude: options.""" + result = list(fields) + fields_opt = self.options.get("fields") + if fields_opt: + allow = {f.strip() for f in fields_opt.split(",")} + result = [f for f in result if f.name in allow] + exclude_opt = self.options.get("exclude") + if exclude_opt: + deny = {f.strip() for f in exclude_opt.split(",")} + result = [f for f in result if f.name not in deny] + return result + + def _build_field_table(self, fields: list[ModelFieldInfo]) -> nodes.table: + """Build a field table.""" + headers = ["Field", "Type", "Required", "Default", "Description"] + rows: list[list[str | nodes.Node]] = [] + for f in fields: + type_cell, _is_enum = _make_type_cell_smart(f.type_str) + + default_cell: str | nodes.Node = "\u2014" + if f.default and f.default != "None": + default_cell = _make_para(_make_literal(f.default)) + + desc = f.description or "\u2014" + + rows.append( + [ + _make_para(_make_literal(f.name)), + type_cell, + "yes" if f.required else "no", + default_cell, + desc, + ] + ) + return _make_table(headers, rows, col_widths=[15, 15, 8, 10, 52]) + + +class FastMCPModelFieldsDirective(SphinxDirective): + """Emit the field table for a model without a section wrapper. + + Useful for embedding model fields inline in other content. + + Options: + - ``:fields:`` — comma-separated allowlist of fields to include + - ``:exclude:`` — comma-separated denylist of fields to exclude + - ``:link-header:`` — if set, adds a header linking to the model section + + Usage:: + + ```{fastmcp-model-fields} SessionInfo + ``` + """ + + required_arguments = 1 + optional_arguments = 0 + has_content = False + option_spec: t.ClassVar[dict[str, t.Any]] = { + "fields": lambda x: x, + "exclude": lambda x: x, + "link-header": lambda x: x, + } + + def run(self) -> list[nodes.Node]: + """Build field table nodes.""" + model_name = self.arguments[0].strip() + + models: dict[str, ModelInfo] = getattr(self.env, "fastmcp_models", {}) + model = models.get(model_name) + + if model is None: + return [ + self.state.document.reporter.warning( + f"fastmcp-model-fields: model '{model_name}' not found.", + line=self.lineno, + ) + ] + + result: list[nodes.Node] = [] + + # Optional link header + link_header = self.options.get("link-header") + if link_header is not None: + ref = nodes.reference("", "", internal=True) + section_id = f"model-{model.name}" + ref["refuri"] = f"#{section_id}" + ref += nodes.literal("", model.name) + result.append(_make_para(ref)) + + # Filter and build table + fields = self._filter_fields(model.fields) + if fields: + headers = ["Field", "Type", "Required", "Default", "Description"] + rows: list[list[str | nodes.Node]] = [] + for f in fields: + type_cell, _is_enum = _make_type_cell_smart(f.type_str) + + default_cell: str | nodes.Node = "\u2014" + if f.default and f.default != "None": + default_cell = _make_para(_make_literal(f.default)) + + desc = f.description or "\u2014" + + rows.append( + [ + _make_para(_make_literal(f.name)), + type_cell, + "yes" if f.required else "no", + default_cell, + desc, + ] + ) + result.append(_make_table(headers, rows, col_widths=[15, 15, 8, 10, 52])) + + return result + + def _filter_fields(self, fields: list[ModelFieldInfo]) -> list[ModelFieldInfo]: + """Apply :fields: and :exclude: options.""" + result = list(fields) + fields_opt = self.options.get("fields") + if fields_opt: + allow = {f.strip() for f in fields_opt.split(",")} + result = [f for f in result if f.name in allow] + exclude_opt = self.options.get("exclude") + if exclude_opt: + deny = {f.strip() for f in exclude_opt.split(",")} + result = [f for f in result if f.name not in deny] + return result + + +class FastMCPModelSummaryDirective(SphinxDirective): + """Generate a summary table of all models. + + Produces a single table with Model and Description columns. + + Usage:: + + ```{fastmcp-modelsummary} + ``` + """ + + required_arguments = 0 + optional_arguments = 0 + has_content = False + + def run(self) -> list[nodes.Node]: + """Build model summary table.""" + models: dict[str, ModelInfo] = getattr(self.env, "fastmcp_models", {}) + + if not models: + return [ + self.state.document.reporter.warning( + "fastmcp-modelsummary: no models found.", + line=self.lineno, + ) + ] + + headers = ["Model", "Description"] + rows: list[list[str | nodes.Node]] = [] + for model in sorted(models.values(), key=lambda m: m.name): + first_line = _first_paragraph(model.docstring) + ref = nodes.reference("", "", internal=True) + section_id = f"model-{model.name}" + ref["refuri"] = f"#{section_id}" + ref += nodes.literal("", model.name) + rows.append( + [ + _make_para(ref), + _parse_rst_inline(first_line, self.state, self.lineno), + ] + ) + + return [_make_table(headers, rows, col_widths=[30, 70])] + + # --------------------------------------------------------------------------- # Extension setup # --------------------------------------------------------------------------- @@ -1059,16 +1721,248 @@ def _badge_role( return [_safety_badge(text.strip())], [] +def _resource_role( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: object, + options: dict[str, object] | None = None, + content: list[str] | None = None, +) -> tuple[list[nodes.Node], list[nodes.system_message]]: + """Inline role ``:resource:`get-sessions``` → linked name + resource badge.""" + target = text.strip().replace("_", "-") + node = _resource_ref_placeholder(rawtext, reftarget=target, show_badge=True) + return [node], [] + + +def _resourceref_role( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: object, + options: dict[str, object] | None = None, + content: list[str] | None = None, +) -> tuple[list[nodes.Node], list[nodes.system_message]]: + """Inline role ``:resourceref:`get-sessions``` → code-linked, no badge.""" + target = text.strip().replace("_", "-") + node = _resource_ref_placeholder(rawtext, reftarget=target, show_badge=False) + return [node], [] + + +def _model_role( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: object, + options: dict[str, object] | None = None, + content: list[str] | None = None, +) -> tuple[list[nodes.Node], list[nodes.system_message]]: + """Inline role ``:model:`SessionInfo``` → linked name + model badge.""" + target = text.strip() + node = _model_ref_placeholder(rawtext, reftarget=target, show_badge=True) + return [node], [] + + +def _modelref_role( + name: str, + rawtext: str, + text: str, + lineno: int, + inliner: object, + options: dict[str, object] | None = None, + content: list[str] | None = None, +) -> tuple[list[nodes.Node], list[nodes.system_message]]: + """Inline role ``:modelref:`SessionInfo``` → code-linked, no badge.""" + target = text.strip() + node = _model_ref_placeholder(rawtext, reftarget=target, show_badge=False) + return [node], [] + + +def _register_resource_labels(app: Sphinx, doctree: nodes.document) -> None: + """Register resource sections with StandardDomain for site-wide {ref} links. + + Same pattern as ``_register_tool_labels`` but for sections with + ``resource-`` prefixed IDs. + """ + domain = t.cast("StandardDomain", app.env.get_domain("std")) + docname = app.env.docname + for section in doctree.findall(nodes.section): + if not section["ids"]: + continue + section_id = section["ids"][0] + if not section_id.startswith("resource-"): + continue + if section.children and isinstance(section[0], nodes.title): + title_node = section[0] + resource_name = "" + for child in title_node.children: + if isinstance(child, nodes.literal): + resource_name = child.astext() + break + if not resource_name: + continue + domain.anonlabels[section_id] = (docname, section_id) + domain.labels[section_id] = (docname, section_id, resource_name) + + +def _register_model_labels(app: Sphinx, doctree: nodes.document) -> None: + """Register model sections with StandardDomain for site-wide {ref} links. + + Same pattern as ``_register_tool_labels`` but for sections with + ``model-`` prefixed IDs. + """ + domain = t.cast("StandardDomain", app.env.get_domain("std")) + docname = app.env.docname + for section in doctree.findall(nodes.section): + if not section["ids"]: + continue + section_id = section["ids"][0] + if not section_id.startswith("model-"): + continue + if section.children and isinstance(section[0], nodes.title): + title_node = section[0] + model_name = "" + for child in title_node.children: + if isinstance(child, nodes.literal): + model_name = child.astext() + break + if not model_name: + continue + domain.anonlabels[section_id] = (docname, section_id) + domain.labels[section_id] = (docname, section_id, model_name) + + +def _resolve_resource_refs( + app: Sphinx, + doctree: nodes.document, + fromdocname: str, +) -> None: + """Resolve ``{resource}`` and ``{resourceref}`` placeholders. + + ``{resource}`` renders as ``code`` + resource badge. + ``{resourceref}`` renders as ``code`` only (no badge). + """ + domain = t.cast("StandardDomain", app.env.get_domain("std")) + builder = app.builder + + for node in list(doctree.findall(_resource_ref_placeholder)): + target = node.get("reftarget", "") + show_badge = node.get("show_badge", True) + # Try resource-prefixed label + label_key = f"resource-{target}" + label_info = domain.labels.get(label_key) + if label_info is None: + node.replace_self(nodes.literal("", target.replace("-", "_"))) + continue + + todocname, labelid, _title = label_info + resource_name = target.replace("-", "_") + + newnode = nodes.reference("", "", internal=True) + try: + newnode["refuri"] = builder.get_relative_uri(fromdocname, todocname) + if labelid: + newnode["refuri"] += "#" + labelid + except Exception: + logger.warning( + "fastmcp_autodoc: failed to resolve URI for %s -> %s", + fromdocname, + todocname, + ) + newnode["refuri"] = "#" + labelid + newnode["classes"].append("reference") + newnode["classes"].append("internal") + + newnode += nodes.literal("", resource_name) + if show_badge: + newnode += nodes.Text(" ") + newnode += _resource_badge() + + node.replace_self(newnode) + + +def _resolve_model_refs( + app: Sphinx, + doctree: nodes.document, + fromdocname: str, +) -> None: + """Resolve ``{model}`` and ``{modelref}`` placeholders. + + ``{model}`` renders as ``code`` + model badge. + ``{modelref}`` renders as ``code`` only (no badge). + """ + domain = t.cast("StandardDomain", app.env.get_domain("std")) + builder = app.builder + + for node in list(doctree.findall(_model_ref_placeholder)): + target = node.get("reftarget", "") + show_badge = node.get("show_badge", True) + # Try model-prefixed label + label_key = f"model-{target}" + label_info = domain.labels.get(label_key) + if label_info is None: + node.replace_self(nodes.literal("", target)) + continue + + todocname, labelid, _title = label_info + + newnode = nodes.reference("", "", internal=True) + try: + newnode["refuri"] = builder.get_relative_uri(fromdocname, todocname) + if labelid: + newnode["refuri"] += "#" + labelid + except Exception: + logger.warning( + "fastmcp_autodoc: failed to resolve URI for %s -> %s", + fromdocname, + todocname, + ) + newnode["refuri"] = "#" + labelid + newnode["classes"].append("reference") + newnode["classes"].append("internal") + + newnode += nodes.literal("", target) + if show_badge: + newnode += nodes.Text(" ") + newnode += _model_badge() + + node.replace_self(newnode) + + def setup(app: Sphinx) -> ExtensionMetadata: """Register the fastmcp_autodoc extension.""" + # Nodes app.add_node( _safety_badge_node, html=(_visit_safety_badge_html, _depart_safety_badge_html), ) - app.connect("builder-inited", _collect_tools) + app.add_node( + _resource_badge_node, + html=(_visit_resource_badge_html, _depart_resource_badge_html), + ) + app.add_node( + _model_badge_node, + html=(_visit_model_badge_html, _depart_model_badge_html), + ) + + # Collection + app.connect("builder-inited", _collect_all) + + # Label registration app.connect("doctree-read", _register_tool_labels) + app.connect("doctree-read", _register_resource_labels) + app.connect("doctree-read", _register_model_labels) + + # Ref resolution app.connect("doctree-resolved", _add_section_badges) app.connect("doctree-resolved", _resolve_tool_refs) + app.connect("doctree-resolved", _resolve_resource_refs) + app.connect("doctree-resolved", _resolve_model_refs) + + # Tool roles app.add_role("tool", _tool_role) app.add_role("toolref", _toolref_role) app.add_role("toolicon", _toolicon_role) @@ -1077,10 +1971,45 @@ def setup(app: Sphinx) -> ExtensionMetadata: app.add_role("tooliconil", _tooliconil_role) app.add_role("tooliconir", _tooliconir_role) app.add_role("badge", _badge_role) + + # Resource roles + app.add_role("resource", _resource_role) + app.add_role("resourceref", _resourceref_role) + + # Model roles + app.add_role("model", _model_role) + app.add_role("modelref", _modelref_role) + + # Tool directives app.add_directive("fastmcp-tool", FastMCPToolDirective) app.add_directive("fastmcp-tool-input", FastMCPToolInputDirective) app.add_directive("fastmcp-toolsummary", FastMCPToolSummaryDirective) + # Resource directives + app.add_directive("fastmcp-resource", FastMCPResourceDirective) + app.add_directive("fastmcp-resourcesummary", FastMCPResourceSummaryDirective) + + # Model directives + app.add_directive("fastmcp-model", FastMCPModelDirective) + app.add_directive("fastmcp-model-fields", FastMCPModelFieldsDirective) + app.add_directive("fastmcp-modelsummary", FastMCPModelSummaryDirective) + + # CSS + app.add_css_file("css/fastmcp_autodoc.css") + + # Strip badge nodes from toctree/sidebar title extraction. + # SphinxContentsFilter walks title nodes to build toctree text. + # Without this, badges inside titles propagate to the sidebar. + # This is the same pattern Sphinx uses for visit_image (SkipNode). + from sphinx.transforms import SphinxContentsFilter + + def _skip_badge(self: t.Any, node: nodes.Node) -> None: + raise nodes.SkipNode + + SphinxContentsFilter.visit__safety_badge_node = _skip_badge # type: ignore[attr-defined] + SphinxContentsFilter.visit__resource_badge_node = _skip_badge # type: ignore[attr-defined] + SphinxContentsFilter.visit__model_badge_node = _skip_badge # type: ignore[attr-defined] + return { "version": "0.1.0", "parallel_read_safe": True, diff --git a/docs/_static/css/fastmcp_autodoc.css b/docs/_static/css/fastmcp_autodoc.css new file mode 100644 index 0000000..8edd903 --- /dev/null +++ b/docs/_static/css/fastmcp_autodoc.css @@ -0,0 +1,37 @@ +/* fastmcp_autodoc.css — resource badge, model badge, URI template, table polish */ + +/* Resource badge (blue) — additional ARIA styling */ +span.sd-badge[aria-label*="resource"] { + user-select: none; +} + +/* Model badge (purple) — additional ARIA styling */ +span.sd-badge[aria-label*="model"] { + user-select: none; +} + +/* URI template block — left-border accent, monospace */ +.fastmcp-uri-template { + border-left: 3px solid var(--sd-color-info, #0dcaf0); + padding: 0.5em 1em; + background: var(--sd-color-info-bg, #f0f9ff); + font-family: var(--sd-fontfamily-monospace, monospace); +} + +/* Badge in section headings — medium size, not full h1 scale */ +h1 .sd-badge, +h2 .sd-badge { + font-size: 0.5em; + vertical-align: middle; +} + +/* Sidebar: strip background from inline code (tool/resource/model names) */ +.sidebar-tree code.literal { + background: none; +} + +/* Field/param table polish — tighter padding */ +.fastmcp-autodoc-table td, +.fastmcp-autodoc-table th { + padding: 0.35em 0.6em; +} diff --git a/docs/demo.md b/docs/demo.md index 2147547..7b45cfd 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -72,6 +72,26 @@ Use {tooliconl}`search-panes` to find text across all panes. If you know which p The fundamental pattern: {toolref}`send-keys` → {toolref}`wait-for-text` → {toolref}`capture-pane`. For discovery: {toolref}`list-sessions` → {toolref}`list-panes` → {toolref}`get-pane-info`. +## Resource references + +### `{resource}` — code-linked with badge + +{resource}`get-sessions` · {resource}`get-session` · {resource}`get-session-windows` · {resource}`get-window` · {resource}`get-pane` · {resource}`get-pane-content` + +### `{resourceref}` — code-linked, no badge + +{resourceref}`get-sessions` · {resourceref}`get-session` · {resourceref}`get-session-windows` · {resourceref}`get-window` · {resourceref}`get-pane` · {resourceref}`get-pane-content` + +## Model references + +### `{model}` — code-linked with badge + +{model}`SessionInfo` · {model}`WindowInfo` · {model}`PaneInfo` · {model}`PaneSnapshot` · {model}`ServerInfo` · {model}`WaitForTextResult` + +### `{modelref}` — code-linked, no badge + +{modelref}`SessionInfo` · {modelref}`WindowInfo` · {modelref}`PaneInfo` · {modelref}`PaneSnapshot` · {modelref}`ServerInfo` · {modelref}`WaitForTextResult` + ## Environment variable references {envvar}`LIBTMUX_SOCKET` · {envvar}`LIBTMUX_SAFETY` · {envvar}`LIBTMUX_SOCKET_PATH` · {envvar}`LIBTMUX_TMUX_BIN` diff --git a/docs/redirects.txt b/docs/redirects.txt index 1baa24d..a5a3d7d 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -12,3 +12,45 @@ "concepts" "topics/concepts" "safety" "topics/safety" "guides/troubleshooting" "topics/troubleshooting" +"tools/sessions" "tools/index" +"tools/windows" "tools/index" +"tools/panes" "tools/index" +"tools/options" "tools/index" +"tools/capture-pane" "tools/pane/capture-pane" +"tools/clear-pane" "tools/pane/clear-pane" +"tools/create-session" "tools/server/create-session" +"tools/create-window" "tools/session/create-window" +"tools/display-message" "tools/pane/display-message" +"tools/enter-copy-mode" "tools/pane/enter-copy-mode" +"tools/exit-copy-mode" "tools/pane/exit-copy-mode" +"tools/get-pane-info" "tools/pane/get-pane-info" +"tools/get-server-info" "tools/server/get-server-info" +"tools/kill-pane" "tools/pane/kill-pane" +"tools/kill-server" "tools/server/kill-server" +"tools/kill-session" "tools/session/kill-session" +"tools/kill-window" "tools/window/kill-window" +"tools/list-panes" "tools/window/list-panes" +"tools/list-sessions" "tools/server/list-sessions" +"tools/list-windows" "tools/session/list-windows" +"tools/move-window" "tools/window/move-window" +"tools/paste-text" "tools/pane/paste-text" +"tools/pipe-pane" "tools/pane/pipe-pane" +"tools/rename-session" "tools/session/rename-session" +"tools/rename-window" "tools/window/rename-window" +"tools/resize-pane" "tools/pane/resize-pane" +"tools/resize-window" "tools/window/resize-window" +"tools/search-panes" "tools/pane/search-panes" +"tools/select-layout" "tools/window/select-layout" +"tools/select-pane" "tools/pane/select-pane" +"tools/select-window" "tools/session/select-window" +"tools/send-keys" "tools/pane/send-keys" +"tools/set-environment" "tools/server/set-environment" +"tools/set-option" "tools/server/set-option" +"tools/set-pane-title" "tools/pane/set-pane-title" +"tools/show-environment" "tools/server/show-environment" +"tools/show-option" "tools/server/show-option" +"tools/snapshot-pane" "tools/pane/snapshot-pane" +"tools/split-window" "tools/window/split-window" +"tools/swap-pane" "tools/pane/swap-pane" +"tools/wait-for-content-change" "tools/pane/wait-for-content-change" +"tools/wait-for-text" "tools/pane/wait-for-text" diff --git a/docs/reference/api/models.md b/docs/reference/api/models.md index 57b63b9..34954af 100644 --- a/docs/reference/api/models.md +++ b/docs/reference/api/models.md @@ -1,8 +1,40 @@ # Models -```{eval-rst} -.. automodule:: libtmux_mcp.models - :members: - :undoc-members: - :show-inheritance: +```{fastmcp-modelsummary} +``` + +```{fastmcp-model} SessionInfo +``` + +```{fastmcp-model} WindowInfo +``` + +```{fastmcp-model} PaneInfo +``` + +```{fastmcp-model} PaneContentMatch +``` + +```{fastmcp-model} ServerInfo +``` + +```{fastmcp-model} OptionResult +``` + +```{fastmcp-model} OptionSetResult +``` + +```{fastmcp-model} EnvironmentResult +``` + +```{fastmcp-model} EnvironmentSetResult +``` + +```{fastmcp-model} WaitForTextResult +``` + +```{fastmcp-model} PaneSnapshot +``` + +```{fastmcp-model} ContentChangeResult ``` diff --git a/docs/reference/api/resources.md b/docs/reference/api/resources.md index 245d916..7f99e3f 100644 --- a/docs/reference/api/resources.md +++ b/docs/reference/api/resources.md @@ -1,8 +1,28 @@ # Resources -```{eval-rst} -.. automodule:: libtmux_mcp.resources.hierarchy - :members: - :undoc-members: - :show-inheritance: +```{fastmcp-resourcesummary} +``` + +## Session Resources + +```{fastmcp-resource} hierarchy.get_sessions +``` + +```{fastmcp-resource} hierarchy.get_session +``` + +```{fastmcp-resource} hierarchy.get_session_windows +``` + +## Window Resources + +```{fastmcp-resource} hierarchy.get_window +``` + +## Pane Resources + +```{fastmcp-resource} hierarchy.get_pane +``` + +```{fastmcp-resource} hierarchy.get_pane_content ``` diff --git a/docs/tools/index.md b/docs/tools/index.md index 0a8e29f..c3c7b98 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -300,8 +300,8 @@ Kill the entire tmux server. ```{toctree} :hidden: -sessions -windows -panes -options +server/index +session/index +window/index +pane/index ``` diff --git a/docs/tools/options.md b/docs/tools/options.md deleted file mode 100644 index 5c3c1b8..0000000 --- a/docs/tools/options.md +++ /dev/null @@ -1,138 +0,0 @@ -# Options & Environment - -## Inspect - -```{fastmcp-tool} option_tools.show_option -``` - -**Use when** you need to check a tmux configuration value — buffer limits, -history size, status bar settings, etc. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "show_option", - "arguments": { - "option": "history-limit" - } -} -``` - -Response: - -```json -{ - "option": "history-limit", - "value": "2000" -} -``` - -```{fastmcp-tool-input} option_tools.show_option -``` - ---- - -```{fastmcp-tool} env_tools.show_environment -``` - -**Use when** you need to inspect tmux environment variables. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "show_environment", - "arguments": {} -} -``` - -Response: - -```json -{ - "variables": { - "SHELL": "/bin/zsh", - "TERM": "xterm-256color", - "HOME": "/home/user", - "USER": "user", - "LANG": "C.UTF-8" - } -} -``` - -```{fastmcp-tool-input} env_tools.show_environment -``` - -## Act - -```{fastmcp-tool} option_tools.set_option -``` - -**Use when** you need to change tmux behavior — adjusting history limits, -enabling mouse support, changing status bar format. - -**Side effects:** Changes the tmux option value. - -**Example:** - -```json -{ - "tool": "set_option", - "arguments": { - "option": "history-limit", - "value": "50000" - } -} -``` - -Response: - -```json -{ - "option": "history-limit", - "value": "50000", - "status": "set" -} -``` - -```{fastmcp-tool-input} option_tools.set_option -``` - ---- - -```{fastmcp-tool} env_tools.set_environment -``` - -**Use when** you need to set a tmux environment variable. - -**Side effects:** Sets the variable in the tmux server. - -**Example:** - -```json -{ - "tool": "set_environment", - "arguments": { - "name": "MY_VAR", - "value": "hello" - } -} -``` - -Response: - -```json -{ - "name": "MY_VAR", - "value": "hello", - "status": "set" -} -``` - -```{fastmcp-tool-input} env_tools.set_environment -``` diff --git a/docs/tools/pane/capture-pane.md b/docs/tools/pane/capture-pane.md new file mode 100644 index 0000000..2b949d3 --- /dev/null +++ b/docs/tools/pane/capture-pane.md @@ -0,0 +1,40 @@ +```{fastmcp-tool} pane_tools.capture_pane +``` + +**Use when** you need to read what's currently displayed in a terminal — +after running a command, checking output, or verifying state. + +**Avoid when** you need to search across multiple panes at once — use +{tooliconl}`search-panes`. If you only need pane metadata (not content), use +{tooliconl}`get-pane-info`. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "capture_pane", + "arguments": { + "pane_id": "%0", + "start": -50 + } +} +``` + +Response (string): + +```text +$ echo "Running tests..." +Running tests... +$ echo "PASS: test_auth (0.3s)" +PASS: test_auth (0.3s) +$ echo "FAIL: test_upload (AssertionError)" +FAIL: test_upload (AssertionError) +$ echo "3 tests: 2 passed, 1 failed" +3 tests: 2 passed, 1 failed +$ +``` + +```{fastmcp-tool-input} pane_tools.capture_pane +``` diff --git a/docs/tools/pane/clear-pane.md b/docs/tools/pane/clear-pane.md new file mode 100644 index 0000000..f670544 --- /dev/null +++ b/docs/tools/pane/clear-pane.md @@ -0,0 +1,26 @@ +```{fastmcp-tool} pane_tools.clear_pane +``` + +**Use when** you want a clean terminal before capturing output. + +**Side effects:** Clears the pane's visible content. + +**Example:** + +```json +{ + "tool": "clear_pane", + "arguments": { + "pane_id": "%0" + } +} +``` + +Response (string): + +```text +Pane cleared: %0 +``` + +```{fastmcp-tool-input} pane_tools.clear_pane +``` diff --git a/docs/tools/pane/display-message.md b/docs/tools/pane/display-message.md new file mode 100644 index 0000000..1c7c0f6 --- /dev/null +++ b/docs/tools/pane/display-message.md @@ -0,0 +1,33 @@ +```{fastmcp-tool} pane_tools.display_message +``` + +**Use when** you need to query arbitrary tmux variables — zoom state, pane +dead flag, client activity, or any `#{format}` string that isn't covered by +other tools. + +**Avoid when** a dedicated tool already provides the information — e.g. use +{tooliconl}`snapshot-pane` for cursor position and mode, or +{tooliconl}`get-pane-info` for standard metadata. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "display_message", + "arguments": { + "format_string": "zoomed=#{window_zoomed_flag} dead=#{pane_dead}", + "pane_id": "%0" + } +} +``` + +Response (string): + +```text +zoomed=0 dead=0 +``` + +```{fastmcp-tool-input} pane_tools.display_message +``` diff --git a/docs/tools/pane/enter-copy-mode.md b/docs/tools/pane/enter-copy-mode.md new file mode 100644 index 0000000..a720e35 --- /dev/null +++ b/docs/tools/pane/enter-copy-mode.md @@ -0,0 +1,44 @@ +```{fastmcp-tool} pane_tools.enter_copy_mode +``` + +**Use when** you need to scroll through scrollback history in a pane. +Optionally scroll up immediately after entering. Use +{tooliconl}`snapshot-pane` afterward to read the `scroll_position` and +visible content. + +**Side effects:** Puts the pane into copy mode. The pane stops receiving +new output until you exit copy mode. + +**Example:** + +```json +{ + "tool": "enter_copy_mode", + "arguments": { + "pane_id": "%0", + "scroll_up": 50 + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "pane_index": "0", + "pane_width": "80", + "pane_height": "24", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.enter_copy_mode +``` diff --git a/docs/tools/pane/exit-copy-mode.md b/docs/tools/pane/exit-copy-mode.md new file mode 100644 index 0000000..174dcd2 --- /dev/null +++ b/docs/tools/pane/exit-copy-mode.md @@ -0,0 +1,40 @@ +```{fastmcp-tool} pane_tools.exit_copy_mode +``` + +**Use when** you're done scrolling through scrollback and want the pane to +resume receiving output. + +**Side effects:** Exits copy mode, returning the pane to normal. + +**Example:** + +```json +{ + "tool": "exit_copy_mode", + "arguments": { + "pane_id": "%0" + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "pane_index": "0", + "pane_width": "80", + "pane_height": "24", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.exit_copy_mode +``` diff --git a/docs/tools/pane/get-pane-info.md b/docs/tools/pane/get-pane-info.md new file mode 100644 index 0000000..85ce43b --- /dev/null +++ b/docs/tools/pane/get-pane-info.md @@ -0,0 +1,42 @@ +```{fastmcp-tool} pane_tools.get_pane_info +``` + +**Use when** you need pane dimensions, PID, current working directory, or +other metadata without reading the terminal content. + +**Avoid when** you need the actual text — use {tooliconl}`capture-pane`. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "get_pane_info", + "arguments": { + "pane_id": "%0" + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "pane_index": "0", + "pane_width": "80", + "pane_height": "24", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.get_pane_info +``` diff --git a/docs/tools/pane/index.md b/docs/tools/pane/index.md new file mode 100644 index 0000000..dab3682 --- /dev/null +++ b/docs/tools/pane/index.md @@ -0,0 +1,155 @@ +# Pane + +Tools for pane-level operations: reading content, sending input, navigation, scrollback, and lifecycle. + +## Inspect + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} capture_pane +:link: capture-pane +:link-type: ref +Read visible content of a pane. +::: + +:::{grid-item-card} get_pane_info +:link: get-pane-info +:link-type: ref +Get detailed pane metadata. +::: + +:::{grid-item-card} search_panes +:link: search-panes +:link-type: ref +Search text across panes. +::: + +:::{grid-item-card} wait_for_text +:link: wait-for-text +:link-type: ref +Wait for text to appear in a pane. +::: + +:::{grid-item-card} snapshot_pane +:link: snapshot-pane +:link-type: ref +Rich capture: content + cursor + mode + scroll. +::: + +:::{grid-item-card} wait_for_content_change +:link: wait-for-content-change +:link-type: ref +Wait for any screen change. +::: + +:::{grid-item-card} display_message +:link: display-message +:link-type: ref +Query arbitrary tmux format strings. +::: + +:::: + +## Act + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} send_keys +:link: send-keys +:link-type: ref +Send commands or keystrokes to a pane. +::: + +:::{grid-item-card} set_pane_title +:link: set-pane-title +:link-type: ref +Set pane title. +::: + +:::{grid-item-card} clear_pane +:link: clear-pane +:link-type: ref +Clear pane content. +::: + +:::{grid-item-card} resize_pane +:link: resize-pane +:link-type: ref +Adjust pane dimensions. +::: + +:::{grid-item-card} select_pane +:link: select-pane +:link-type: ref +Focus a pane by ID or direction. +::: + +:::{grid-item-card} swap_pane +:link: swap-pane +:link-type: ref +Exchange positions of two panes. +::: + +:::{grid-item-card} pipe_pane +:link: pipe-pane +:link-type: ref +Stream pane output to a file. +::: + +:::{grid-item-card} enter_copy_mode +:link: enter-copy-mode +:link-type: ref +Enter copy mode for scrollback. +::: + +:::{grid-item-card} exit_copy_mode +:link: exit-copy-mode +:link-type: ref +Exit copy mode. +::: + +:::{grid-item-card} paste_text +:link: paste-text +:link-type: ref +Paste multi-line text via tmux buffer. +::: + +:::: + +## Destroy + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} kill_pane +:link: kill-pane +:link-type: ref +Destroy a pane. +::: + +:::: + +```{toctree} +:hidden: + +capture-pane +get-pane-info +search-panes +wait-for-text +snapshot-pane +wait-for-content-change +display-message +send-keys +set-pane-title +clear-pane +resize-pane +select-pane +swap-pane +pipe-pane +enter-copy-mode +exit-copy-mode +paste-text +kill-pane +``` diff --git a/docs/tools/pane/kill-pane.md b/docs/tools/pane/kill-pane.md new file mode 100644 index 0000000..7f6158b --- /dev/null +++ b/docs/tools/pane/kill-pane.md @@ -0,0 +1,29 @@ +```{fastmcp-tool} pane_tools.kill_pane +``` + +**Use when** you're done with a specific terminal and want to remove it +without affecting sibling panes. + +**Avoid when** you want to remove the entire window — use {tooliconl}`kill-window`. + +**Side effects:** Destroys the pane. Not reversible. + +**Example:** + +```json +{ + "tool": "kill_pane", + "arguments": { + "pane_id": "%1" + } +} +``` + +Response (string): + +```text +Pane killed: %1 +``` + +```{fastmcp-tool-input} pane_tools.kill_pane +``` diff --git a/docs/tools/pane/paste-text.md b/docs/tools/pane/paste-text.md new file mode 100644 index 0000000..aa974bc --- /dev/null +++ b/docs/tools/pane/paste-text.md @@ -0,0 +1,31 @@ +```{fastmcp-tool} pane_tools.paste_text +``` + +**Use when** you need to paste multi-line text into a pane — e.g. a code +block, a config snippet, or a heredoc. Uses tmux paste buffers for clean +multi-line input instead of sending text line-by-line via +{tooliconl}`send-keys`. + +**Side effects:** Pastes text into the pane. With `bracket=true` (default), +uses bracketed paste mode so the terminal knows this is pasted text. + +**Example:** + +```json +{ + "tool": "paste_text", + "arguments": { + "text": "def hello():\n print('world')\n", + "pane_id": "%0" + } +} +``` + +Response (string): + +```text +Text pasted to pane %0 +``` + +```{fastmcp-tool-input} pane_tools.paste_text +``` diff --git a/docs/tools/pane/pipe-pane.md b/docs/tools/pane/pipe-pane.md new file mode 100644 index 0000000..48f4261 --- /dev/null +++ b/docs/tools/pane/pipe-pane.md @@ -0,0 +1,33 @@ +```{fastmcp-tool} pane_tools.pipe_pane +``` + +**Use when** you need to log pane output to a file — useful for monitoring +long-running processes or capturing output that scrolls past the visible +area. + +**Avoid when** you only need a one-time capture — use {tooliconl}`capture-pane` +with `start`/`end` to read scrollback. + +**Side effects:** Starts or stops piping output to a file. Call with +`output_path=null` to stop. + +**Example:** + +```json +{ + "tool": "pipe_pane", + "arguments": { + "pane_id": "%0", + "output_path": "/tmp/build.log" + } +} +``` + +Response (string): + +```text +Piping pane %0 to /tmp/build.log +``` + +```{fastmcp-tool-input} pane_tools.pipe_pane +``` diff --git a/docs/tools/pane/resize-pane.md b/docs/tools/pane/resize-pane.md new file mode 100644 index 0000000..2cc7fcd --- /dev/null +++ b/docs/tools/pane/resize-pane.md @@ -0,0 +1,40 @@ +```{fastmcp-tool} pane_tools.resize_pane +``` + +**Use when** you need to adjust pane dimensions. + +**Side effects:** Changes pane size. May affect adjacent panes. + +**Example:** + +```json +{ + "tool": "resize_pane", + "arguments": { + "pane_id": "%0", + "height": 15 + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "pane_index": "0", + "pane_width": "80", + "pane_height": "15", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.resize_pane +``` diff --git a/docs/tools/pane/search-panes.md b/docs/tools/pane/search-panes.md new file mode 100644 index 0000000..6dd8f16 --- /dev/null +++ b/docs/tools/pane/search-panes.md @@ -0,0 +1,47 @@ +```{fastmcp-tool} pane_tools.search_panes +``` + +**Use when** you need to find specific text across multiple panes — locating +which pane has an error, finding a running process, or checking output +without knowing which pane to look in. + +**Avoid when** you already know the target pane — use {tooliconl}`capture-pane` +directly. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "search_panes", + "arguments": { + "pattern": "FAIL", + "session_name": "dev" + } +} +``` + +Response: + +```json +[ + { + "pane_id": "%0", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "window_id": "@0", + "window_name": "editor", + "session_id": "$0", + "session_name": "dev", + "matched_lines": [ + "FAIL: test_upload (AssertionError)", + "3 tests: 2 passed, 1 failed" + ], + "is_caller": null + } +] +``` + +```{fastmcp-tool-input} pane_tools.search_panes +``` diff --git a/docs/tools/pane/select-pane.md b/docs/tools/pane/select-pane.md new file mode 100644 index 0000000..7495aa6 --- /dev/null +++ b/docs/tools/pane/select-pane.md @@ -0,0 +1,42 @@ +```{fastmcp-tool} pane_tools.select_pane +``` + +**Use when** you need to focus a specific pane — by ID for a known target, +or by direction (`up`, `down`, `left`, `right`, `last`, `next`, `previous`) +to navigate a multi-pane layout. + +**Side effects:** Changes the active pane in the window. + +**Example:** + +```json +{ + "tool": "select_pane", + "arguments": { + "direction": "down", + "window_id": "@0" + } +} +``` + +Response: + +```json +{ + "pane_id": "%1", + "pane_index": "1", + "pane_width": "80", + "pane_height": "11", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12400", + "pane_title": "", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.select_pane +``` diff --git a/docs/tools/pane/send-keys.md b/docs/tools/pane/send-keys.md new file mode 100644 index 0000000..4bdaa75 --- /dev/null +++ b/docs/tools/pane/send-keys.md @@ -0,0 +1,32 @@ +```{fastmcp-tool} pane_tools.send_keys +``` + +**Use when** you need to type commands, press keys, or interact with a +terminal. This is the primary way to execute commands in tmux panes. + +**Avoid when** you need to run something and immediately capture the result — +send keys first, then use {tooliconl}`capture-pane` or {tooliconl}`wait-for-text`. + +**Side effects:** Sends keystrokes to the pane. If `enter` is true (default), +the command executes. + +**Example:** + +```json +{ + "tool": "send_keys", + "arguments": { + "keys": "npm start", + "pane_id": "%2" + } +} +``` + +Response (string): + +```text +Keys sent to pane %2 +``` + +```{fastmcp-tool-input} pane_tools.send_keys +``` diff --git a/docs/tools/pane/set-pane-title.md b/docs/tools/pane/set-pane-title.md new file mode 100644 index 0000000..65ec2d8 --- /dev/null +++ b/docs/tools/pane/set-pane-title.md @@ -0,0 +1,40 @@ +```{fastmcp-tool} pane_tools.set_pane_title +``` + +**Use when** you want to label a pane for identification. + +**Side effects:** Changes the pane title. + +**Example:** + +```json +{ + "tool": "set_pane_title", + "arguments": { + "pane_id": "%0", + "title": "build" + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "pane_index": "0", + "pane_width": "80", + "pane_height": "24", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "build", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.set_pane_title +``` diff --git a/docs/tools/pane/snapshot-pane.md b/docs/tools/pane/snapshot-pane.md new file mode 100644 index 0000000..3ee2747 --- /dev/null +++ b/docs/tools/pane/snapshot-pane.md @@ -0,0 +1,47 @@ +```{fastmcp-tool} pane_tools.snapshot_pane +``` + +**Use when** you need a complete picture of a pane in a single call — visible +text plus cursor position, whether the pane is in copy mode, scroll offset, +and scrollback history size. Replaces separate `capture_pane` + +`get_pane_info` calls when you need to reason about cursor location or +terminal mode. + +**Avoid when** you only need raw text — {tooliconl}`capture-pane` is lighter. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "snapshot_pane", + "arguments": { + "pane_id": "%0" + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "content": "$ npm test\n\nPASS src/auth.test.ts\nTests: 3 passed\n$", + "cursor_x": 2, + "cursor_y": 4, + "pane_width": 80, + "pane_height": 24, + "pane_in_mode": false, + "pane_mode": null, + "scroll_position": null, + "history_size": 142, + "title": "", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.snapshot_pane +``` diff --git a/docs/tools/pane/swap-pane.md b/docs/tools/pane/swap-pane.md new file mode 100644 index 0000000..d179952 --- /dev/null +++ b/docs/tools/pane/swap-pane.md @@ -0,0 +1,41 @@ +```{fastmcp-tool} pane_tools.swap_pane +``` + +**Use when** you want to rearrange pane positions without changing content — +e.g. moving a log pane from bottom to top. + +**Side effects:** Exchanges the visual positions of two panes. + +**Example:** + +```json +{ + "tool": "swap_pane", + "arguments": { + "source_pane_id": "%0", + "target_pane_id": "%1" + } +} +``` + +Response: + +```json +{ + "pane_id": "%0", + "pane_index": "1", + "pane_width": "80", + "pane_height": "11", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} pane_tools.swap_pane +``` diff --git a/docs/tools/pane/wait-for-content-change.md b/docs/tools/pane/wait-for-content-change.md new file mode 100644 index 0000000..da84111 --- /dev/null +++ b/docs/tools/pane/wait-for-content-change.md @@ -0,0 +1,38 @@ +```{fastmcp-tool} pane_tools.wait_for_content_change +``` + +**Use when** you've sent a command and need to wait for *something* to happen, +but you don't know what the output will look like. Unlike +{tooliconl}`wait-for-text`, this waits for *any* screen change rather than a +specific pattern. + +**Avoid when** you know the expected output — {tooliconl}`wait-for-text` is more +precise and avoids false positives from unrelated output. + +**Side effects:** None. Readonly. Blocks until content changes or timeout. + +**Example:** + +```json +{ + "tool": "wait_for_content_change", + "arguments": { + "pane_id": "%0", + "timeout": 10 + } +} +``` + +Response: + +```json +{ + "changed": true, + "pane_id": "%0", + "elapsed_seconds": 1.234, + "timed_out": false +} +``` + +```{fastmcp-tool-input} pane_tools.wait_for_content_change +``` diff --git a/docs/tools/pane/wait-for-text.md b/docs/tools/pane/wait-for-text.md new file mode 100644 index 0000000..91aaaf6 --- /dev/null +++ b/docs/tools/pane/wait-for-text.md @@ -0,0 +1,42 @@ +```{fastmcp-tool} pane_tools.wait_for_text +``` + +**Use when** you need to block until specific output appears — waiting for a +server to start, a build to complete, or a prompt to return. + +**Avoid when** the expected text may never appear — always set a reasonable +`timeout`. For known output, {tooliconl}`capture-pane` after a known delay +may suffice, but `wait_for_text` is preferred because it adapts to variable +timing. + +**Side effects:** None. Readonly. Blocks until text appears or timeout. + +**Example:** + +```json +{ + "tool": "wait_for_text", + "arguments": { + "pattern": "Server listening", + "pane_id": "%2", + "timeout": 30 + } +} +``` + +Response: + +```json +{ + "found": true, + "matched_lines": [ + "Server listening on port 8000" + ], + "pane_id": "%2", + "elapsed_seconds": 0.002, + "timed_out": false +} +``` + +```{fastmcp-tool-input} pane_tools.wait_for_text +``` diff --git a/docs/tools/panes.md b/docs/tools/panes.md deleted file mode 100644 index 73b3202..0000000 --- a/docs/tools/panes.md +++ /dev/null @@ -1,742 +0,0 @@ -# Panes - -## Inspect - -```{fastmcp-tool} pane_tools.capture_pane -``` - -**Use when** you need to read what's currently displayed in a terminal — -after running a command, checking output, or verifying state. - -**Avoid when** you need to search across multiple panes at once — use -{tooliconl}`search-panes`. If you only need pane metadata (not content), use -{tooliconl}`get-pane-info`. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "capture_pane", - "arguments": { - "pane_id": "%0", - "start": -50 - } -} -``` - -Response (string): - -```text -$ echo "Running tests..." -Running tests... -$ echo "PASS: test_auth (0.3s)" -PASS: test_auth (0.3s) -$ echo "FAIL: test_upload (AssertionError)" -FAIL: test_upload (AssertionError) -$ echo "3 tests: 2 passed, 1 failed" -3 tests: 2 passed, 1 failed -$ -``` - -```{fastmcp-tool-input} pane_tools.capture_pane -``` - ---- - -```{fastmcp-tool} pane_tools.get_pane_info -``` - -**Use when** you need pane dimensions, PID, current working directory, or -other metadata without reading the terminal content. - -**Avoid when** you need the actual text — use {tooliconl}`capture-pane`. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "get_pane_info", - "arguments": { - "pane_id": "%0" - } -} -``` - -Response: - -```json -{ - "pane_id": "%0", - "pane_index": "0", - "pane_width": "80", - "pane_height": "24", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "12345", - "pane_title": "", - "pane_active": "1", - "window_id": "@0", - "session_id": "$0", - "is_caller": null -} -``` - -```{fastmcp-tool-input} pane_tools.get_pane_info -``` - ---- - -```{fastmcp-tool} pane_tools.search_panes -``` - -**Use when** you need to find specific text across multiple panes — locating -which pane has an error, finding a running process, or checking output -without knowing which pane to look in. - -**Avoid when** you already know the target pane — use {tooliconl}`capture-pane` -directly. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "search_panes", - "arguments": { - "pattern": "FAIL", - "session_name": "dev" - } -} -``` - -Response: - -```json -[ - { - "pane_id": "%0", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "window_id": "@0", - "window_name": "editor", - "session_id": "$0", - "session_name": "dev", - "matched_lines": [ - "FAIL: test_upload (AssertionError)", - "3 tests: 2 passed, 1 failed" - ], - "is_caller": null - } -] -``` - -```{fastmcp-tool-input} pane_tools.search_panes -``` - ---- - -```{fastmcp-tool} pane_tools.wait_for_text -``` - -**Use when** you need to block until specific output appears — waiting for a -server to start, a build to complete, or a prompt to return. - -**Avoid when** the expected text may never appear — always set a reasonable -`timeout`. For known output, {tooliconl}`capture-pane` after a known delay -may suffice, but `wait_for_text` is preferred because it adapts to variable -timing. - -**Side effects:** None. Readonly. Blocks until text appears or timeout. - -**Example:** - -```json -{ - "tool": "wait_for_text", - "arguments": { - "pattern": "Server listening", - "pane_id": "%2", - "timeout": 30 - } -} -``` - -Response: - -```json -{ - "found": true, - "matched_lines": [ - "Server listening on port 8000" - ], - "pane_id": "%2", - "elapsed_seconds": 0.002, - "timed_out": false -} -``` - -```{fastmcp-tool-input} pane_tools.wait_for_text -``` - ---- - -```{fastmcp-tool} pane_tools.snapshot_pane -``` - -**Use when** you need a complete picture of a pane in a single call — visible -text plus cursor position, whether the pane is in copy mode, scroll offset, -and scrollback history size. Replaces separate `capture_pane` + -`get_pane_info` calls when you need to reason about cursor location or -terminal mode. - -**Avoid when** you only need raw text — {tooliconl}`capture-pane` is lighter. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "snapshot_pane", - "arguments": { - "pane_id": "%0" - } -} -``` - -Response: - -```json -{ - "pane_id": "%0", - "content": "$ npm test\n\nPASS src/auth.test.ts\nTests: 3 passed\n$", - "cursor_x": 2, - "cursor_y": 4, - "pane_width": 80, - "pane_height": 24, - "pane_in_mode": false, - "pane_mode": null, - "scroll_position": null, - "history_size": 142, - "title": "", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "is_caller": null -} -``` - -```{fastmcp-tool-input} pane_tools.snapshot_pane -``` - ---- - -```{fastmcp-tool} pane_tools.wait_for_content_change -``` - -**Use when** you've sent a command and need to wait for *something* to happen, -but you don't know what the output will look like. Unlike -{tooliconl}`wait-for-text`, this waits for *any* screen change rather than a -specific pattern. - -**Avoid when** you know the expected output — {tooliconl}`wait-for-text` is more -precise and avoids false positives from unrelated output. - -**Side effects:** None. Readonly. Blocks until content changes or timeout. - -**Example:** - -```json -{ - "tool": "wait_for_content_change", - "arguments": { - "pane_id": "%0", - "timeout": 10 - } -} -``` - -Response: - -```json -{ - "changed": true, - "pane_id": "%0", - "elapsed_seconds": 1.234, - "timed_out": false -} -``` - -```{fastmcp-tool-input} pane_tools.wait_for_content_change -``` - ---- - -```{fastmcp-tool} pane_tools.display_message -``` - -**Use when** you need to query arbitrary tmux variables — zoom state, pane -dead flag, client activity, or any `#{format}` string that isn't covered by -other tools. - -**Avoid when** a dedicated tool already provides the information — e.g. use -{tooliconl}`snapshot-pane` for cursor position and mode, or -{tooliconl}`get-pane-info` for standard metadata. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "display_message", - "arguments": { - "format_string": "zoomed=#{window_zoomed_flag} dead=#{pane_dead}", - "pane_id": "%0" - } -} -``` - -Response (string): - -```text -zoomed=0 dead=0 -``` - -```{fastmcp-tool-input} pane_tools.display_message -``` - -## Act - -```{fastmcp-tool} pane_tools.send_keys -``` - -**Use when** you need to type commands, press keys, or interact with a -terminal. This is the primary way to execute commands in tmux panes. - -**Avoid when** you need to run something and immediately capture the result — -send keys first, then use {tooliconl}`capture-pane` or {tooliconl}`wait-for-text`. - -**Side effects:** Sends keystrokes to the pane. If `enter` is true (default), -the command executes. - -**Example:** - -```json -{ - "tool": "send_keys", - "arguments": { - "keys": "npm start", - "pane_id": "%2" - } -} -``` - -Response (string): - -```text -Keys sent to pane %2 -``` - -```{fastmcp-tool-input} pane_tools.send_keys -``` - ---- - -```{fastmcp-tool} pane_tools.set_pane_title -``` - -**Use when** you want to label a pane for identification. - -**Side effects:** Changes the pane title. - -**Example:** - -```json -{ - "tool": "set_pane_title", - "arguments": { - "pane_id": "%0", - "title": "build" - } -} -``` - -Response: - -```json -{ - "pane_id": "%0", - "pane_index": "0", - "pane_width": "80", - "pane_height": "24", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "12345", - "pane_title": "build", - "pane_active": "1", - "window_id": "@0", - "session_id": "$0", - "is_caller": null -} -``` - -```{fastmcp-tool-input} pane_tools.set_pane_title -``` - ---- - -```{fastmcp-tool} pane_tools.clear_pane -``` - -**Use when** you want a clean terminal before capturing output. - -**Side effects:** Clears the pane's visible content. - -**Example:** - -```json -{ - "tool": "clear_pane", - "arguments": { - "pane_id": "%0" - } -} -``` - -Response (string): - -```text -Pane cleared: %0 -``` - -```{fastmcp-tool-input} pane_tools.clear_pane -``` - ---- - -```{fastmcp-tool} pane_tools.resize_pane -``` - -**Use when** you need to adjust pane dimensions. - -**Side effects:** Changes pane size. May affect adjacent panes. - -**Example:** - -```json -{ - "tool": "resize_pane", - "arguments": { - "pane_id": "%0", - "height": 15 - } -} -``` - -Response: - -```json -{ - "pane_id": "%0", - "pane_index": "0", - "pane_width": "80", - "pane_height": "15", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "12345", - "pane_title": "", - "pane_active": "1", - "window_id": "@0", - "session_id": "$0", - "is_caller": null -} -``` - -```{fastmcp-tool-input} pane_tools.resize_pane -``` - ---- - -```{fastmcp-tool} pane_tools.select_pane -``` - -**Use when** you need to focus a specific pane — by ID for a known target, -or by direction (`up`, `down`, `left`, `right`, `last`, `next`, `previous`) -to navigate a multi-pane layout. - -**Side effects:** Changes the active pane in the window. - -**Example:** - -```json -{ - "tool": "select_pane", - "arguments": { - "direction": "down", - "window_id": "@0" - } -} -``` - -Response: - -```json -{ - "pane_id": "%1", - "pane_index": "1", - "pane_width": "80", - "pane_height": "11", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "12400", - "pane_title": "", - "pane_active": "1", - "window_id": "@0", - "session_id": "$0", - "is_caller": null -} -``` - -```{fastmcp-tool-input} pane_tools.select_pane -``` - ---- - -```{fastmcp-tool} pane_tools.swap_pane -``` - -**Use when** you want to rearrange pane positions without changing content — -e.g. moving a log pane from bottom to top. - -**Side effects:** Exchanges the visual positions of two panes. - -**Example:** - -```json -{ - "tool": "swap_pane", - "arguments": { - "source_pane_id": "%0", - "target_pane_id": "%1" - } -} -``` - -Response: - -```json -{ - "pane_id": "%0", - "pane_index": "1", - "pane_width": "80", - "pane_height": "11", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "12345", - "pane_title": "", - "pane_active": "1", - "window_id": "@0", - "session_id": "$0", - "is_caller": null -} -``` - -```{fastmcp-tool-input} pane_tools.swap_pane -``` - ---- - -```{fastmcp-tool} pane_tools.pipe_pane -``` - -**Use when** you need to log pane output to a file — useful for monitoring -long-running processes or capturing output that scrolls past the visible -area. - -**Avoid when** you only need a one-time capture — use {tooliconl}`capture-pane` -with `start`/`end` to read scrollback. - -**Side effects:** Starts or stops piping output to a file. Call with -`output_path=null` to stop. - -**Example:** - -```json -{ - "tool": "pipe_pane", - "arguments": { - "pane_id": "%0", - "output_path": "/tmp/build.log" - } -} -``` - -Response (string): - -```text -Piping pane %0 to /tmp/build.log -``` - -```{fastmcp-tool-input} pane_tools.pipe_pane -``` - ---- - -```{fastmcp-tool} pane_tools.enter_copy_mode -``` - -**Use when** you need to scroll through scrollback history in a pane. -Optionally scroll up immediately after entering. Use -{tooliconl}`snapshot-pane` afterward to read the `scroll_position` and -visible content. - -**Side effects:** Puts the pane into copy mode. The pane stops receiving -new output until you exit copy mode. - -**Example:** - -```json -{ - "tool": "enter_copy_mode", - "arguments": { - "pane_id": "%0", - "scroll_up": 50 - } -} -``` - -Response: - -```json -{ - "pane_id": "%0", - "pane_index": "0", - "pane_width": "80", - "pane_height": "24", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "12345", - "pane_title": "", - "pane_active": "1", - "window_id": "@0", - "session_id": "$0", - "is_caller": null -} -``` - -```{fastmcp-tool-input} pane_tools.enter_copy_mode -``` - ---- - -```{fastmcp-tool} pane_tools.exit_copy_mode -``` - -**Use when** you're done scrolling through scrollback and want the pane to -resume receiving output. - -**Side effects:** Exits copy mode, returning the pane to normal. - -**Example:** - -```json -{ - "tool": "exit_copy_mode", - "arguments": { - "pane_id": "%0" - } -} -``` - -Response: - -```json -{ - "pane_id": "%0", - "pane_index": "0", - "pane_width": "80", - "pane_height": "24", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "12345", - "pane_title": "", - "pane_active": "1", - "window_id": "@0", - "session_id": "$0", - "is_caller": null -} -``` - -```{fastmcp-tool-input} pane_tools.exit_copy_mode -``` - ---- - -```{fastmcp-tool} pane_tools.paste_text -``` - -**Use when** you need to paste multi-line text into a pane — e.g. a code -block, a config snippet, or a heredoc. Uses tmux paste buffers for clean -multi-line input instead of sending text line-by-line via -{tooliconl}`send-keys`. - -**Side effects:** Pastes text into the pane. With `bracket=true` (default), -uses bracketed paste mode so the terminal knows this is pasted text. - -**Example:** - -```json -{ - "tool": "paste_text", - "arguments": { - "text": "def hello():\n print('world')\n", - "pane_id": "%0" - } -} -``` - -Response (string): - -```text -Text pasted to pane %0 -``` - -```{fastmcp-tool-input} pane_tools.paste_text -``` - -## Destroy - -```{fastmcp-tool} pane_tools.kill_pane -``` - -**Use when** you're done with a specific terminal and want to remove it -without affecting sibling panes. - -**Avoid when** you want to remove the entire window — use {tooliconl}`kill-window`. - -**Side effects:** Destroys the pane. Not reversible. - -**Example:** - -```json -{ - "tool": "kill_pane", - "arguments": { - "pane_id": "%1" - } -} -``` - -Response (string): - -```text -Pane killed: %1 -``` - -```{fastmcp-tool-input} pane_tools.kill_pane -``` diff --git a/docs/tools/server/create-session.md b/docs/tools/server/create-session.md new file mode 100644 index 0000000..fdc1a49 --- /dev/null +++ b/docs/tools/server/create-session.md @@ -0,0 +1,36 @@ +```{fastmcp-tool} server_tools.create_session +``` + +**Use when** you need a new isolated workspace. Sessions are the top-level +container — create one before creating windows or panes. + +**Avoid when** a session with the target name already exists — check with +{tooliconl}`list-sessions` first, or the command will fail. + +**Side effects:** Creates a new tmux session with one window and one pane. + +**Example:** + +```json +{ + "tool": "create_session", + "arguments": { + "session_name": "dev" + } +} +``` + +Response: + +```json +{ + "session_id": "$1", + "session_name": "dev", + "window_count": 1, + "session_attached": "0", + "session_created": "1774521872" +} +``` + +```{fastmcp-tool-input} server_tools.create_session +``` diff --git a/docs/tools/server/get-server-info.md b/docs/tools/server/get-server-info.md new file mode 100644 index 0000000..2e1727d --- /dev/null +++ b/docs/tools/server/get-server-info.md @@ -0,0 +1,33 @@ +```{fastmcp-tool} server_tools.get_server_info +``` + +**Use when** you need to verify the tmux server is running, check its PID, +or inspect server-level state before creating sessions. + +**Avoid when** you only need session names — use {tooliconl}`list-sessions`. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "get_server_info", + "arguments": {} +} +``` + +Response: + +```json +{ + "is_alive": true, + "socket_name": null, + "socket_path": null, + "session_count": 2, + "version": "3.6a" +} +``` + +```{fastmcp-tool-input} server_tools.get_server_info +``` diff --git a/docs/tools/server/index.md b/docs/tools/server/index.md new file mode 100644 index 0000000..f14d294 --- /dev/null +++ b/docs/tools/server/index.md @@ -0,0 +1,69 @@ +# Server + +Tools for server-level operations: session discovery, server info, configuration, and lifecycle. + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} list_sessions +:link: list-sessions +:link-type: ref +List all active sessions. +::: + +:::{grid-item-card} get_server_info +:link: get-server-info +:link-type: ref +Get tmux server info. +::: + +:::{grid-item-card} create_session +:link: create-session +:link-type: ref +Start a new tmux session. +::: + +:::{grid-item-card} kill_server +:link: kill-server +:link-type: ref +Kill the entire tmux server. +::: + +:::{grid-item-card} show_option +:link: show-option +:link-type: ref +Query a tmux option value. +::: + +:::{grid-item-card} set_option +:link: set-option +:link-type: ref +Set a tmux option. +::: + +:::{grid-item-card} show_environment +:link: show-environment +:link-type: ref +Show tmux environment variables. +::: + +:::{grid-item-card} set_environment +:link: set-environment +:link-type: ref +Set a tmux environment variable. +::: + +:::: + +```{toctree} +:hidden: + +list-sessions +get-server-info +create-session +kill-server +show-option +set-option +show-environment +set-environment +``` diff --git a/docs/tools/server/kill-server.md b/docs/tools/server/kill-server.md new file mode 100644 index 0000000..c4a0194 --- /dev/null +++ b/docs/tools/server/kill-server.md @@ -0,0 +1,27 @@ +```{fastmcp-tool} server_tools.kill_server +``` + +**Use when** you need to tear down the entire tmux server. This kills every +session, window, and pane. + +**Avoid when** you only need to remove one session — use {tooliconl}`kill-session`. + +**Side effects:** Destroys everything. Not reversible. + +**Example:** + +```json +{ + "tool": "kill_server", + "arguments": {} +} +``` + +Response (string): + +```text +Server killed successfully +``` + +```{fastmcp-tool-input} server_tools.kill_server +``` diff --git a/docs/tools/server/list-sessions.md b/docs/tools/server/list-sessions.md new file mode 100644 index 0000000..adca66e --- /dev/null +++ b/docs/tools/server/list-sessions.md @@ -0,0 +1,36 @@ +```{fastmcp-tool} server_tools.list_sessions +``` + +**Use when** you need session names, IDs, or attached status before deciding +which session to target. + +**Avoid when** you need window or pane details — use {tooliconl}`list-windows` or +{tooliconl}`list-panes` instead. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "list_sessions", + "arguments": {} +} +``` + +Response: + +```json +[ + { + "session_id": "$0", + "session_name": "myproject", + "window_count": 2, + "session_attached": "0", + "session_created": "1774521871" + } +] +``` + +```{fastmcp-tool-input} server_tools.list_sessions +``` diff --git a/docs/tools/server/set-environment.md b/docs/tools/server/set-environment.md new file mode 100644 index 0000000..4d50c8a --- /dev/null +++ b/docs/tools/server/set-environment.md @@ -0,0 +1,31 @@ +```{fastmcp-tool} env_tools.set_environment +``` + +**Use when** you need to set a tmux environment variable. + +**Side effects:** Sets the variable in the tmux server. + +**Example:** + +```json +{ + "tool": "set_environment", + "arguments": { + "name": "MY_VAR", + "value": "hello" + } +} +``` + +Response: + +```json +{ + "name": "MY_VAR", + "value": "hello", + "status": "set" +} +``` + +```{fastmcp-tool-input} env_tools.set_environment +``` diff --git a/docs/tools/server/set-option.md b/docs/tools/server/set-option.md new file mode 100644 index 0000000..a27d5be --- /dev/null +++ b/docs/tools/server/set-option.md @@ -0,0 +1,32 @@ +```{fastmcp-tool} option_tools.set_option +``` + +**Use when** you need to change tmux behavior — adjusting history limits, +enabling mouse support, changing status bar format. + +**Side effects:** Changes the tmux option value. + +**Example:** + +```json +{ + "tool": "set_option", + "arguments": { + "option": "history-limit", + "value": "50000" + } +} +``` + +Response: + +```json +{ + "option": "history-limit", + "value": "50000", + "status": "set" +} +``` + +```{fastmcp-tool-input} option_tools.set_option +``` diff --git a/docs/tools/server/show-environment.md b/docs/tools/server/show-environment.md new file mode 100644 index 0000000..66171ce --- /dev/null +++ b/docs/tools/server/show-environment.md @@ -0,0 +1,32 @@ +```{fastmcp-tool} env_tools.show_environment +``` + +**Use when** you need to inspect tmux environment variables. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "show_environment", + "arguments": {} +} +``` + +Response: + +```json +{ + "variables": { + "SHELL": "/bin/zsh", + "TERM": "xterm-256color", + "HOME": "/home/user", + "USER": "user", + "LANG": "C.UTF-8" + } +} +``` + +```{fastmcp-tool-input} env_tools.show_environment +``` diff --git a/docs/tools/server/show-option.md b/docs/tools/server/show-option.md new file mode 100644 index 0000000..4aa597a --- /dev/null +++ b/docs/tools/server/show-option.md @@ -0,0 +1,30 @@ +```{fastmcp-tool} option_tools.show_option +``` + +**Use when** you need to check a tmux configuration value — buffer limits, +history size, status bar settings, etc. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "show_option", + "arguments": { + "option": "history-limit" + } +} +``` + +Response: + +```json +{ + "option": "history-limit", + "value": "2000" +} +``` + +```{fastmcp-tool-input} option_tools.show_option +``` diff --git a/docs/tools/session/create-window.md b/docs/tools/session/create-window.md new file mode 100644 index 0000000..db6602a --- /dev/null +++ b/docs/tools/session/create-window.md @@ -0,0 +1,38 @@ +```{fastmcp-tool} session_tools.create_window +``` + +**Use when** you need a new terminal workspace within an existing session. + +**Side effects:** Creates a new window. Attaches to it if `attach` is true. + +**Example:** + +```json +{ + "tool": "create_window", + "arguments": { + "session_name": "dev", + "window_name": "logs" + } +} +``` + +Response: + +```json +{ + "window_id": "@2", + "window_name": "logs", + "window_index": "3", + "session_id": "$0", + "session_name": "dev", + "pane_count": 1, + "window_layout": "b25f,80x24,0,0,5", + "window_active": "1", + "window_width": "80", + "window_height": "24" +} +``` + +```{fastmcp-tool-input} session_tools.create_window +``` diff --git a/docs/tools/session/index.md b/docs/tools/session/index.md new file mode 100644 index 0000000..17053b3 --- /dev/null +++ b/docs/tools/session/index.md @@ -0,0 +1,48 @@ +# Session + +Tools for session-level operations: window management, session renaming, and lifecycle. + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} list_windows +:link: list-windows +:link-type: ref +List windows in a session. +::: + +:::{grid-item-card} create_window +:link: create-window +:link-type: ref +Add a window to a session. +::: + +:::{grid-item-card} rename_session +:link: rename-session +:link-type: ref +Rename a session. +::: + +:::{grid-item-card} select_window +:link: select-window +:link-type: ref +Focus a window by ID, index, or direction. +::: + +:::{grid-item-card} kill_session +:link: kill-session +:link-type: ref +Destroy a session and all its windows. +::: + +:::: + +```{toctree} +:hidden: + +list-windows +create-window +rename-session +select-window +kill-session +``` diff --git a/docs/tools/session/kill-session.md b/docs/tools/session/kill-session.md new file mode 100644 index 0000000..9f27f1e --- /dev/null +++ b/docs/tools/session/kill-session.md @@ -0,0 +1,29 @@ +```{fastmcp-tool} session_tools.kill_session +``` + +**Use when** you're done with a workspace and want to clean up. Kills all +windows and panes in the session. + +**Avoid when** you only want to close one window — use {tooliconl}`kill-window`. + +**Side effects:** Destroys the session and all its contents. Not reversible. + +**Example:** + +```json +{ + "tool": "kill_session", + "arguments": { + "session_name": "old-workspace" + } +} +``` + +Response (string): + +```text +Session killed: old-workspace +``` + +```{fastmcp-tool-input} session_tools.kill_session +``` diff --git a/docs/tools/session/list-windows.md b/docs/tools/session/list-windows.md new file mode 100644 index 0000000..240931b --- /dev/null +++ b/docs/tools/session/list-windows.md @@ -0,0 +1,54 @@ +```{fastmcp-tool} session_tools.list_windows +``` + +**Use when** you need window names, indices, or layout metadata within a +session before selecting a window to work with. + +**Avoid when** you need pane-level detail — use {tooliconl}`list-panes`. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "list_windows", + "arguments": { + "session_name": "dev" + } +} +``` + +Response: + +```json +[ + { + "window_id": "@0", + "window_name": "editor", + "window_index": "1", + "session_id": "$0", + "session_name": "dev", + "pane_count": 2, + "window_layout": "c195,80x24,0,0[80x12,0,0,0,80x11,0,13,1]", + "window_active": "1", + "window_width": "80", + "window_height": "24" + }, + { + "window_id": "@1", + "window_name": "server", + "window_index": "2", + "session_id": "$0", + "session_name": "dev", + "pane_count": 1, + "window_layout": "b25f,80x24,0,0,2", + "window_active": "0", + "window_width": "80", + "window_height": "24" + } +] +``` + +```{fastmcp-tool-input} session_tools.list_windows +``` diff --git a/docs/tools/session/rename-session.md b/docs/tools/session/rename-session.md new file mode 100644 index 0000000..b335a39 --- /dev/null +++ b/docs/tools/session/rename-session.md @@ -0,0 +1,33 @@ +```{fastmcp-tool} session_tools.rename_session +``` + +**Use when** a session name no longer reflects its purpose. + +**Side effects:** Renames the session. Existing references by old name will break. + +**Example:** + +```json +{ + "tool": "rename_session", + "arguments": { + "session_name": "old-name", + "new_name": "new-name" + } +} +``` + +Response: + +```json +{ + "session_id": "$0", + "session_name": "new-name", + "window_count": 2, + "session_attached": "0", + "session_created": "1774521871" +} +``` + +```{fastmcp-tool-input} session_tools.rename_session +``` diff --git a/docs/tools/session/select-window.md b/docs/tools/session/select-window.md new file mode 100644 index 0000000..0ebe289 --- /dev/null +++ b/docs/tools/session/select-window.md @@ -0,0 +1,39 @@ +```{fastmcp-tool} session_tools.select_window +``` + +**Use when** you need to switch focus to a different window — by ID, index, +or direction (`next`, `previous`, `last`). + +**Side effects:** Changes the active window in the session. + +**Example:** + +```json +{ + "tool": "select_window", + "arguments": { + "direction": "next", + "session_name": "dev" + } +} +``` + +Response: + +```json +{ + "window_id": "@1", + "window_name": "server", + "window_index": "2", + "session_id": "$0", + "session_name": "dev", + "pane_count": 1, + "window_layout": "b25f,80x24,0,0,2", + "window_active": "1", + "window_width": "80", + "window_height": "24" +} +``` + +```{fastmcp-tool-input} session_tools.select_window +``` diff --git a/docs/tools/sessions.md b/docs/tools/sessions.md deleted file mode 100644 index 984f042..0000000 --- a/docs/tools/sessions.md +++ /dev/null @@ -1,255 +0,0 @@ -# Sessions - -## Inspect - -```{fastmcp-tool} server_tools.list_sessions -``` - -**Use when** you need session names, IDs, or attached status before deciding -which session to target. - -**Avoid when** you need window or pane details — use {tooliconl}`list-windows` or -{tooliconl}`list-panes` instead. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "list_sessions", - "arguments": {} -} -``` - -Response: - -```json -[ - { - "session_id": "$0", - "session_name": "myproject", - "window_count": 2, - "session_attached": "0", - "session_created": "1774521871" - } -] -``` - -```{fastmcp-tool-input} server_tools.list_sessions -``` - ---- - -```{fastmcp-tool} server_tools.get_server_info -``` - -**Use when** you need to verify the tmux server is running, check its PID, -or inspect server-level state before creating sessions. - -**Avoid when** you only need session names — use {tooliconl}`list-sessions`. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "get_server_info", - "arguments": {} -} -``` - -Response: - -```json -{ - "is_alive": true, - "socket_name": null, - "socket_path": null, - "session_count": 2, - "version": "3.6a" -} -``` - -```{fastmcp-tool-input} server_tools.get_server_info -``` - -## Act - -```{fastmcp-tool} server_tools.create_session -``` - -**Use when** you need a new isolated workspace. Sessions are the top-level -container — create one before creating windows or panes. - -**Avoid when** a session with the target name already exists — check with -{tooliconl}`list-sessions` first, or the command will fail. - -**Side effects:** Creates a new tmux session with one window and one pane. - -**Example:** - -```json -{ - "tool": "create_session", - "arguments": { - "session_name": "dev" - } -} -``` - -Response: - -```json -{ - "session_id": "$1", - "session_name": "dev", - "window_count": 1, - "session_attached": "0", - "session_created": "1774521872" -} -``` - -```{fastmcp-tool-input} server_tools.create_session -``` - ---- - -```{fastmcp-tool} session_tools.rename_session -``` - -**Use when** a session name no longer reflects its purpose. - -**Side effects:** Renames the session. Existing references by old name will break. - -**Example:** - -```json -{ - "tool": "rename_session", - "arguments": { - "session_name": "old-name", - "new_name": "new-name" - } -} -``` - -Response: - -```json -{ - "session_id": "$0", - "session_name": "new-name", - "window_count": 2, - "session_attached": "0", - "session_created": "1774521871" -} -``` - -```{fastmcp-tool-input} session_tools.rename_session -``` - ---- - -```{fastmcp-tool} session_tools.select_window -``` - -**Use when** you need to switch focus to a different window — by ID, index, -or direction (`next`, `previous`, `last`). - -**Side effects:** Changes the active window in the session. - -**Example:** - -```json -{ - "tool": "select_window", - "arguments": { - "direction": "next", - "session_name": "dev" - } -} -``` - -Response: - -```json -{ - "window_id": "@1", - "window_name": "server", - "window_index": "2", - "session_id": "$0", - "session_name": "dev", - "pane_count": 1, - "window_layout": "b25f,80x24,0,0,2", - "window_active": "1", - "window_width": "80", - "window_height": "24" -} -``` - -```{fastmcp-tool-input} session_tools.select_window -``` - -## Destroy - -```{fastmcp-tool} session_tools.kill_session -``` - -**Use when** you're done with a workspace and want to clean up. Kills all -windows and panes in the session. - -**Avoid when** you only want to close one window — use {tooliconl}`kill-window`. - -**Side effects:** Destroys the session and all its contents. Not reversible. - -**Example:** - -```json -{ - "tool": "kill_session", - "arguments": { - "session_name": "old-workspace" - } -} -``` - -Response (string): - -```text -Session killed: old-workspace -``` - -```{fastmcp-tool-input} session_tools.kill_session -``` - ---- - -```{fastmcp-tool} server_tools.kill_server -``` - -**Use when** you need to tear down the entire tmux server. This kills every -session, window, and pane. - -**Avoid when** you only need to remove one session — use {tooliconl}`kill-session`. - -**Side effects:** Destroys everything. Not reversible. - -**Example:** - -```json -{ - "tool": "kill_server", - "arguments": {} -} -``` - -Response (string): - -```text -Server killed successfully -``` - -```{fastmcp-tool-input} server_tools.kill_server -``` diff --git a/docs/tools/window/index.md b/docs/tools/window/index.md new file mode 100644 index 0000000..bcf091b --- /dev/null +++ b/docs/tools/window/index.md @@ -0,0 +1,62 @@ +# Window + +Tools for window-level operations: pane management, layout, resizing, and lifecycle. + +::::{grid} 1 2 3 3 +:gutter: 2 2 3 3 + +:::{grid-item-card} list_panes +:link: list-panes +:link-type: ref +List panes in a window. +::: + +:::{grid-item-card} split_window +:link: split-window +:link-type: ref +Split a window into panes. +::: + +:::{grid-item-card} rename_window +:link: rename-window +:link-type: ref +Rename a window. +::: + +:::{grid-item-card} select_layout +:link: select-layout +:link-type: ref +Set window layout. +::: + +:::{grid-item-card} resize_window +:link: resize-window +:link-type: ref +Adjust window dimensions. +::: + +:::{grid-item-card} move_window +:link: move-window +:link-type: ref +Move window to another index or session. +::: + +:::{grid-item-card} kill_window +:link: kill-window +:link-type: ref +Destroy a window and all its panes. +::: + +:::: + +```{toctree} +:hidden: + +list-panes +split-window +rename-window +select-layout +resize-window +move-window +kill-window +``` diff --git a/docs/tools/window/kill-window.md b/docs/tools/window/kill-window.md new file mode 100644 index 0000000..1f84c80 --- /dev/null +++ b/docs/tools/window/kill-window.md @@ -0,0 +1,28 @@ +```{fastmcp-tool} window_tools.kill_window +``` + +**Use when** you're done with a window and all its panes. + +**Avoid when** you only want to remove one pane — use {tooliconl}`kill-pane`. + +**Side effects:** Destroys the window and all its panes. Not reversible. + +**Example:** + +```json +{ + "tool": "kill_window", + "arguments": { + "window_id": "@1" + } +} +``` + +Response (string): + +```text +Window killed: @1 +``` + +```{fastmcp-tool-input} window_tools.kill_window +``` diff --git a/docs/tools/window/list-panes.md b/docs/tools/window/list-panes.md new file mode 100644 index 0000000..a093ed4 --- /dev/null +++ b/docs/tools/window/list-panes.md @@ -0,0 +1,56 @@ +```{fastmcp-tool} window_tools.list_panes +``` + +**Use when** you need to discover which panes exist in a window before +sending keys or capturing output. + +**Side effects:** None. Readonly. + +**Example:** + +```json +{ + "tool": "list_panes", + "arguments": { + "session_name": "dev" + } +} +``` + +Response: + +```json +[ + { + "pane_id": "%0", + "pane_index": "0", + "pane_width": "80", + "pane_height": "15", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12345", + "pane_title": "build", + "pane_active": "1", + "window_id": "@0", + "session_id": "$0", + "is_caller": null + }, + { + "pane_id": "%1", + "pane_index": "1", + "pane_width": "80", + "pane_height": "8", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "12400", + "pane_title": "", + "pane_active": "0", + "window_id": "@0", + "session_id": "$0", + "is_caller": null + } +] +``` + +```{fastmcp-tool-input} window_tools.list_panes +``` diff --git a/docs/tools/window/move-window.md b/docs/tools/window/move-window.md new file mode 100644 index 0000000..db687e2 --- /dev/null +++ b/docs/tools/window/move-window.md @@ -0,0 +1,39 @@ +```{fastmcp-tool} window_tools.move_window +``` + +**Use when** you need to reorder windows within a session or move a window +to a different session entirely. + +**Side effects:** Changes the window's index or parent session. + +**Example:** + +```json +{ + "tool": "move_window", + "arguments": { + "window_id": "@1", + "destination_index": "1" + } +} +``` + +Response: + +```json +{ + "window_id": "@1", + "window_name": "server", + "window_index": "1", + "session_id": "$0", + "session_name": "dev", + "pane_count": 1, + "window_layout": "b25f,80x24,0,0,2", + "window_active": "0", + "window_width": "80", + "window_height": "24" +} +``` + +```{fastmcp-tool-input} window_tools.move_window +``` diff --git a/docs/tools/window/rename-window.md b/docs/tools/window/rename-window.md new file mode 100644 index 0000000..c2c8c6d --- /dev/null +++ b/docs/tools/window/rename-window.md @@ -0,0 +1,38 @@ +```{fastmcp-tool} window_tools.rename_window +``` + +**Use when** a window name no longer reflects its purpose. + +**Side effects:** Renames the window. + +**Example:** + +```json +{ + "tool": "rename_window", + "arguments": { + "session_name": "dev", + "new_name": "build" + } +} +``` + +Response: + +```json +{ + "window_id": "@0", + "window_name": "build", + "window_index": "1", + "session_id": "$0", + "session_name": "dev", + "pane_count": 2, + "window_layout": "7f9f,80x24,0,0[80x15,0,0,0,80x8,0,16,1]", + "window_active": "1", + "window_width": "80", + "window_height": "24" +} +``` + +```{fastmcp-tool-input} window_tools.rename_window +``` diff --git a/docs/tools/window/resize-window.md b/docs/tools/window/resize-window.md new file mode 100644 index 0000000..b59ca70 --- /dev/null +++ b/docs/tools/window/resize-window.md @@ -0,0 +1,39 @@ +```{fastmcp-tool} window_tools.resize_window +``` + +**Use when** you need to adjust the window dimensions. + +**Side effects:** Changes window size. + +**Example:** + +```json +{ + "tool": "resize_window", + "arguments": { + "session_name": "dev", + "width": 120, + "height": 40 + } +} +``` + +Response: + +```json +{ + "window_id": "@0", + "window_name": "editor", + "window_index": "1", + "session_id": "$0", + "session_name": "dev", + "pane_count": 2, + "window_layout": "baaa,120x40,0,0[120x20,0,0,0,120x19,0,21,1]", + "window_active": "1", + "window_width": "120", + "window_height": "40" +} +``` + +```{fastmcp-tool-input} window_tools.resize_window +``` diff --git a/docs/tools/window/select-layout.md b/docs/tools/window/select-layout.md new file mode 100644 index 0000000..8a2146f --- /dev/null +++ b/docs/tools/window/select-layout.md @@ -0,0 +1,39 @@ +```{fastmcp-tool} window_tools.select_layout +``` + +**Use when** you want to rearrange panes — `even-horizontal`, +`even-vertical`, `main-horizontal`, `main-vertical`, or `tiled`. + +**Side effects:** Rearranges all panes in the window. + +**Example:** + +```json +{ + "tool": "select_layout", + "arguments": { + "session_name": "dev", + "layout": "even-vertical" + } +} +``` + +Response: + +```json +{ + "window_id": "@0", + "window_name": "editor", + "window_index": "1", + "session_id": "$0", + "session_name": "dev", + "pane_count": 2, + "window_layout": "even-vertical,80x24,0,0[80x12,0,0,0,80x11,0,13,1]", + "window_active": "1", + "window_width": "80", + "window_height": "24" +} +``` + +```{fastmcp-tool-input} window_tools.select_layout +``` diff --git a/docs/tools/window/split-window.md b/docs/tools/window/split-window.md new file mode 100644 index 0000000..07acdec --- /dev/null +++ b/docs/tools/window/split-window.md @@ -0,0 +1,41 @@ +```{fastmcp-tool} window_tools.split_window +``` + +**Use when** you need side-by-side or stacked terminals within the same +window. + +**Side effects:** Creates a new pane by splitting an existing one. + +**Example:** + +```json +{ + "tool": "split_window", + "arguments": { + "session_name": "dev", + "direction": "right" + } +} +``` + +Response: + +```json +{ + "pane_id": "%4", + "pane_index": "1", + "pane_width": "39", + "pane_height": "24", + "pane_current_command": "zsh", + "pane_current_path": "/home/user/myproject", + "pane_pid": "3732", + "pane_title": "", + "pane_active": "0", + "window_id": "@0", + "session_id": "$0", + "is_caller": null +} +``` + +```{fastmcp-tool-input} window_tools.split_window +``` diff --git a/docs/tools/windows.md b/docs/tools/windows.md deleted file mode 100644 index 91684c0..0000000 --- a/docs/tools/windows.md +++ /dev/null @@ -1,400 +0,0 @@ -# Windows - -## Inspect - -```{fastmcp-tool} session_tools.list_windows -``` - -**Use when** you need window names, indices, or layout metadata within a -session before selecting a window to work with. - -**Avoid when** you need pane-level detail — use {tooliconl}`list-panes`. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "list_windows", - "arguments": { - "session_name": "dev" - } -} -``` - -Response: - -```json -[ - { - "window_id": "@0", - "window_name": "editor", - "window_index": "1", - "session_id": "$0", - "session_name": "dev", - "pane_count": 2, - "window_layout": "c195,80x24,0,0[80x12,0,0,0,80x11,0,13,1]", - "window_active": "1", - "window_width": "80", - "window_height": "24" - }, - { - "window_id": "@1", - "window_name": "server", - "window_index": "2", - "session_id": "$0", - "session_name": "dev", - "pane_count": 1, - "window_layout": "b25f,80x24,0,0,2", - "window_active": "0", - "window_width": "80", - "window_height": "24" - } -] -``` - -```{fastmcp-tool-input} session_tools.list_windows -``` - ---- - -```{fastmcp-tool} window_tools.list_panes -``` - -**Use when** you need to discover which panes exist in a window before -sending keys or capturing output. - -**Side effects:** None. Readonly. - -**Example:** - -```json -{ - "tool": "list_panes", - "arguments": { - "session_name": "dev" - } -} -``` - -Response: - -```json -[ - { - "pane_id": "%0", - "pane_index": "0", - "pane_width": "80", - "pane_height": "15", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "12345", - "pane_title": "build", - "pane_active": "1", - "window_id": "@0", - "session_id": "$0", - "is_caller": null - }, - { - "pane_id": "%1", - "pane_index": "1", - "pane_width": "80", - "pane_height": "8", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "12400", - "pane_title": "", - "pane_active": "0", - "window_id": "@0", - "session_id": "$0", - "is_caller": null - } -] -``` - -```{fastmcp-tool-input} window_tools.list_panes -``` - -## Act - -```{fastmcp-tool} session_tools.create_window -``` - -**Use when** you need a new terminal workspace within an existing session. - -**Side effects:** Creates a new window. Attaches to it if `attach` is true. - -**Example:** - -```json -{ - "tool": "create_window", - "arguments": { - "session_name": "dev", - "window_name": "logs" - } -} -``` - -Response: - -```json -{ - "window_id": "@2", - "window_name": "logs", - "window_index": "3", - "session_id": "$0", - "session_name": "dev", - "pane_count": 1, - "window_layout": "b25f,80x24,0,0,5", - "window_active": "1", - "window_width": "80", - "window_height": "24" -} -``` - -```{fastmcp-tool-input} session_tools.create_window -``` - ---- - -```{fastmcp-tool} window_tools.split_window -``` - -**Use when** you need side-by-side or stacked terminals within the same -window. - -**Side effects:** Creates a new pane by splitting an existing one. - -**Example:** - -```json -{ - "tool": "split_window", - "arguments": { - "session_name": "dev", - "direction": "right" - } -} -``` - -Response: - -```json -{ - "pane_id": "%4", - "pane_index": "1", - "pane_width": "39", - "pane_height": "24", - "pane_current_command": "zsh", - "pane_current_path": "/home/user/myproject", - "pane_pid": "3732", - "pane_title": "", - "pane_active": "0", - "window_id": "@0", - "session_id": "$0", - "is_caller": null -} -``` - -```{fastmcp-tool-input} window_tools.split_window -``` - ---- - -```{fastmcp-tool} window_tools.rename_window -``` - -**Use when** a window name no longer reflects its purpose. - -**Side effects:** Renames the window. - -**Example:** - -```json -{ - "tool": "rename_window", - "arguments": { - "session_name": "dev", - "new_name": "build" - } -} -``` - -Response: - -```json -{ - "window_id": "@0", - "window_name": "build", - "window_index": "1", - "session_id": "$0", - "session_name": "dev", - "pane_count": 2, - "window_layout": "7f9f,80x24,0,0[80x15,0,0,0,80x8,0,16,1]", - "window_active": "1", - "window_width": "80", - "window_height": "24" -} -``` - -```{fastmcp-tool-input} window_tools.rename_window -``` - ---- - -```{fastmcp-tool} window_tools.select_layout -``` - -**Use when** you want to rearrange panes — `even-horizontal`, -`even-vertical`, `main-horizontal`, `main-vertical`, or `tiled`. - -**Side effects:** Rearranges all panes in the window. - -**Example:** - -```json -{ - "tool": "select_layout", - "arguments": { - "session_name": "dev", - "layout": "even-vertical" - } -} -``` - -Response: - -```json -{ - "window_id": "@0", - "window_name": "editor", - "window_index": "1", - "session_id": "$0", - "session_name": "dev", - "pane_count": 2, - "window_layout": "even-vertical,80x24,0,0[80x12,0,0,0,80x11,0,13,1]", - "window_active": "1", - "window_width": "80", - "window_height": "24" -} -``` - -```{fastmcp-tool-input} window_tools.select_layout -``` - ---- - -```{fastmcp-tool} window_tools.resize_window -``` - -**Use when** you need to adjust the window dimensions. - -**Side effects:** Changes window size. - -**Example:** - -```json -{ - "tool": "resize_window", - "arguments": { - "session_name": "dev", - "width": 120, - "height": 40 - } -} -``` - -Response: - -```json -{ - "window_id": "@0", - "window_name": "editor", - "window_index": "1", - "session_id": "$0", - "session_name": "dev", - "pane_count": 2, - "window_layout": "baaa,120x40,0,0[120x20,0,0,0,120x19,0,21,1]", - "window_active": "1", - "window_width": "120", - "window_height": "40" -} -``` - -```{fastmcp-tool-input} window_tools.resize_window -``` - ---- - -```{fastmcp-tool} window_tools.move_window -``` - -**Use when** you need to reorder windows within a session or move a window -to a different session entirely. - -**Side effects:** Changes the window's index or parent session. - -**Example:** - -```json -{ - "tool": "move_window", - "arguments": { - "window_id": "@1", - "destination_index": "1" - } -} -``` - -Response: - -```json -{ - "window_id": "@1", - "window_name": "server", - "window_index": "1", - "session_id": "$0", - "session_name": "dev", - "pane_count": 1, - "window_layout": "b25f,80x24,0,0,2", - "window_active": "0", - "window_width": "80", - "window_height": "24" -} -``` - -```{fastmcp-tool-input} window_tools.move_window -``` - -## Destroy - -```{fastmcp-tool} window_tools.kill_window -``` - -**Use when** you're done with a window and all its panes. - -**Avoid when** you only want to remove one pane — use {tooliconl}`kill-pane`. - -**Side effects:** Destroys the window and all its panes. Not reversible. - -**Example:** - -```json -{ - "tool": "kill_window", - "arguments": { - "window_id": "@1" - } -} -``` - -Response (string): - -```text -Window killed: @1 -``` - -```{fastmcp-tool-input} window_tools.kill_window -``` diff --git a/tests/docs/_ext/test_fastmcp_autodoc.py b/tests/docs/_ext/test_fastmcp_autodoc.py index a2ed9fa..1876999 100644 --- a/tests/docs/_ext/test_fastmcp_autodoc.py +++ b/tests/docs/_ext/test_fastmcp_autodoc.py @@ -665,6 +665,29 @@ def test_collect_real_tools() -> None: assert socket_param.required is False +# --------------------------------------------------------------------------- +# _discover_model_classes +# --------------------------------------------------------------------------- + + +def test_discover_model_classes_finds_all_12() -> None: + """_discover_model_classes finds all 12 Pydantic models.""" + classes = fastmcp_autodoc._discover_model_classes() + assert len(classes) == 12 + + +def test_discover_model_classes_includes_pane_snapshot() -> None: + """_discover_model_classes includes PaneSnapshot (previously missing).""" + classes = fastmcp_autodoc._discover_model_classes() + assert "PaneSnapshot" in classes + + +def test_discover_model_classes_includes_content_change_result() -> None: + """_discover_model_classes includes ContentChangeResult (previously missing).""" + classes = fastmcp_autodoc._discover_model_classes() + assert "ContentChangeResult" in classes + + def test_collect_real_tools_total_count() -> None: """All 38 tools should be collected.""" collector = fastmcp_autodoc._ToolCollector() diff --git a/tests/docs/_ext/test_fastmcp_models.py b/tests/docs/_ext/test_fastmcp_models.py new file mode 100644 index 0000000..03abfa4 --- /dev/null +++ b/tests/docs/_ext/test_fastmcp_models.py @@ -0,0 +1,280 @@ +"""Tests for fastmcp_autodoc model collection and nodes.""" + +from __future__ import annotations + +import typing as t +from unittest.mock import MagicMock + +import fastmcp_autodoc +import pytest + +# --------------------------------------------------------------------------- +# _collect_models +# --------------------------------------------------------------------------- + + +def _collect_models_result() -> dict[str, fastmcp_autodoc.ModelInfo]: + """Run _collect_models and return the result dict.""" + app = MagicMock() + app.env = MagicMock() + fastmcp_autodoc._collect_models(app) + result: dict[str, fastmcp_autodoc.ModelInfo] = app.env.fastmcp_models + return result + + +def test_collect_models_discovers_all_12() -> None: + """_collect_models finds all 12 Pydantic models in libtmux_mcp.models.""" + models = _collect_models_result() + assert len(models) == 12 + + +class ModelNameFixture(t.NamedTuple): + """Test fixture for verifying model names.""" + + test_id: str + name: str + + +MODEL_NAME_FIXTURES: list[ModelNameFixture] = [ + ModelNameFixture(test_id="SessionInfo", name="SessionInfo"), + ModelNameFixture(test_id="WindowInfo", name="WindowInfo"), + ModelNameFixture(test_id="PaneInfo", name="PaneInfo"), + ModelNameFixture(test_id="PaneContentMatch", name="PaneContentMatch"), + ModelNameFixture(test_id="ServerInfo", name="ServerInfo"), + ModelNameFixture(test_id="OptionResult", name="OptionResult"), + ModelNameFixture(test_id="OptionSetResult", name="OptionSetResult"), + ModelNameFixture(test_id="EnvironmentResult", name="EnvironmentResult"), + ModelNameFixture(test_id="EnvironmentSetResult", name="EnvironmentSetResult"), + ModelNameFixture(test_id="WaitForTextResult", name="WaitForTextResult"), + ModelNameFixture(test_id="PaneSnapshot", name="PaneSnapshot"), + ModelNameFixture(test_id="ContentChangeResult", name="ContentChangeResult"), +] + + +@pytest.mark.parametrize( + MODEL_NAME_FIXTURES[0]._fields, + MODEL_NAME_FIXTURES, + ids=[f.test_id for f in MODEL_NAME_FIXTURES], +) +def test_collect_models_includes_model(test_id: str, name: str) -> None: + """_collect_models includes each expected model.""" + models = _collect_models_result() + assert name in models + + +# --------------------------------------------------------------------------- +# Field counts +# --------------------------------------------------------------------------- + + +class FieldCountFixture(t.NamedTuple): + """Test fixture for field count verification.""" + + test_id: str + model_name: str + expected_count: int + + +FIELD_COUNT_FIXTURES: list[FieldCountFixture] = [ + FieldCountFixture( + test_id="SessionInfo_5", + model_name="SessionInfo", + expected_count=5, + ), + FieldCountFixture( + test_id="PaneSnapshot_14", + model_name="PaneSnapshot", + expected_count=14, + ), + FieldCountFixture( + test_id="WindowInfo_10", + model_name="WindowInfo", + expected_count=10, + ), + FieldCountFixture( + test_id="PaneInfo_12", + model_name="PaneInfo", + expected_count=12, + ), + FieldCountFixture( + test_id="ContentChangeResult_4", + model_name="ContentChangeResult", + expected_count=4, + ), + FieldCountFixture( + test_id="WaitForTextResult_5", + model_name="WaitForTextResult", + expected_count=5, + ), +] + + +@pytest.mark.parametrize( + FIELD_COUNT_FIXTURES[0]._fields, + FIELD_COUNT_FIXTURES, + ids=[f.test_id for f in FIELD_COUNT_FIXTURES], +) +def test_model_field_count( + test_id: str, + model_name: str, + expected_count: int, +) -> None: + """Models have the expected number of fields.""" + models = _collect_models_result() + model = models[model_name] + assert len(model.fields) == expected_count + + +# --------------------------------------------------------------------------- +# Field description extraction +# --------------------------------------------------------------------------- + + +def test_field_description_extraction() -> None: + """Field(description=...) values are extracted correctly.""" + models = _collect_models_result() + session = models["SessionInfo"] + field_map = {f.name: f for f in session.fields} + + assert "session_id" in field_map + assert field_map["session_id"].description == "Session ID (e.g. '$1')" + + assert "window_count" in field_map + assert field_map["window_count"].description == "Number of windows" + + +# --------------------------------------------------------------------------- +# Required vs optional detection +# --------------------------------------------------------------------------- + + +def test_required_vs_optional_detection() -> None: + """Required fields and optional fields are distinguished correctly.""" + models = _collect_models_result() + session = models["SessionInfo"] + field_map = {f.name: f for f in session.fields} + + # session_id has no default → required + assert field_map["session_id"].required is True + assert field_map["session_id"].default == "" + + # session_name has default=None → optional + assert field_map["session_name"].required is False + assert field_map["session_name"].default == "None" + + # window_count has no default → required + assert field_map["window_count"].required is True + + +# --------------------------------------------------------------------------- +# default_factory handling +# --------------------------------------------------------------------------- + + +def test_default_factory_handling() -> None: + """WaitForTextResult.matched_lines uses default_factory=list.""" + models = _collect_models_result() + wait_result = models["WaitForTextResult"] + field_map = {f.name: f for f in wait_result.fields} + + matched_lines = field_map["matched_lines"] + assert matched_lines.required is False + assert matched_lines.default == "list()" + + +# --------------------------------------------------------------------------- +# _model_badge_node +# --------------------------------------------------------------------------- + + +def test_model_badge_classes() -> None: + """_model_badge creates badge node with correct CSS classes.""" + badge = fastmcp_autodoc._model_badge() + assert isinstance(badge, fastmcp_autodoc._model_badge_node) + assert "sd-bg-primary" in badge["classes"] + assert "sd-bg-text-primary" in badge["classes"] + assert "sd-sphinx-override" in badge["classes"] + assert "sd-badge" in badge["classes"] + assert badge.astext() == "model" + + +# --------------------------------------------------------------------------- +# Model roles +# --------------------------------------------------------------------------- + + +def test_model_role_creates_placeholder() -> None: + """_model_role creates _model_ref_placeholder with show_badge=True.""" + result_nodes, _messages = fastmcp_autodoc._model_role( + "model", ":model:`SessionInfo`", "SessionInfo", 1, None + ) + assert len(result_nodes) == 1 + node = result_nodes[0] + assert isinstance(node, fastmcp_autodoc._model_ref_placeholder) + assert node["reftarget"] == "SessionInfo" + assert node["show_badge"] is True + + +def test_modelref_role_creates_placeholder() -> None: + """_modelref_role creates _model_ref_placeholder with show_badge=False.""" + result_nodes, _messages = fastmcp_autodoc._modelref_role( + "modelref", ":modelref:`SessionInfo`", "SessionInfo", 1, None + ) + assert len(result_nodes) == 1 + node = result_nodes[0] + assert isinstance(node, fastmcp_autodoc._model_ref_placeholder) + assert node["reftarget"] == "SessionInfo" + assert node["show_badge"] is False + + +# --------------------------------------------------------------------------- +# :fields: and :exclude: filtering +# --------------------------------------------------------------------------- + + +def test_model_directive_fields_allowlist() -> None: + """FastMCPModelDirective :fields: option filters to allowed fields.""" + directive = fastmcp_autodoc.FastMCPModelDirective.__new__( + fastmcp_autodoc.FastMCPModelDirective + ) + directive.options = {"fields": "session_id, window_count"} + + all_fields = [ + fastmcp_autodoc.ModelFieldInfo("session_id", "str", True, "", ""), + fastmcp_autodoc.ModelFieldInfo("session_name", "str | None", False, "None", ""), + fastmcp_autodoc.ModelFieldInfo("window_count", "int", True, "", ""), + ] + + filtered = directive._filter_fields(all_fields) + names = [f.name for f in filtered] + assert names == ["session_id", "window_count"] + + +def test_model_directive_exclude_denylist() -> None: + """FastMCPModelDirective :exclude: option removes denied fields.""" + directive = fastmcp_autodoc.FastMCPModelDirective.__new__( + fastmcp_autodoc.FastMCPModelDirective + ) + directive.options = {"exclude": "session_name"} + + all_fields = [ + fastmcp_autodoc.ModelFieldInfo("session_id", "str", True, "", ""), + fastmcp_autodoc.ModelFieldInfo("session_name", "str | None", False, "None", ""), + fastmcp_autodoc.ModelFieldInfo("window_count", "int", True, "", ""), + ] + + filtered = directive._filter_fields(all_fields) + names = [f.name for f in filtered] + assert names == ["session_id", "window_count"] + + +# --------------------------------------------------------------------------- +# Qualified names +# --------------------------------------------------------------------------- + + +def test_model_qualified_name() -> None: + """Model qualified_name includes full module path.""" + models = _collect_models_result() + assert models["SessionInfo"].qualified_name == "libtmux_mcp.models.SessionInfo" + assert models["PaneSnapshot"].qualified_name == "libtmux_mcp.models.PaneSnapshot" diff --git a/tests/docs/_ext/test_fastmcp_resources.py b/tests/docs/_ext/test_fastmcp_resources.py new file mode 100644 index 0000000..e4b66d4 --- /dev/null +++ b/tests/docs/_ext/test_fastmcp_resources.py @@ -0,0 +1,202 @@ +"""Tests for fastmcp_autodoc resource collection and nodes.""" + +from __future__ import annotations + +import typing as t + +import fastmcp_autodoc +import pytest + +# --------------------------------------------------------------------------- +# _ResourceCollector +# --------------------------------------------------------------------------- + + +def test_resource_collector_captures_registrations() -> None: + """_ResourceCollector captures resource metadata from register() calls.""" + collector = fastmcp_autodoc._ResourceCollector() + collector._current_module = "hierarchy" + + @collector.resource("tmux://sessions{?socket_name}", title="All Sessions") + def get_sessions(socket_name: str | None = None) -> str: + """List all tmux sessions. + + Parameters + ---------- + socket_name : str, optional + tmux socket name. + + Returns + ------- + str + """ + return "" + + assert len(collector.resources) == 1 + resource = collector.resources[0] + assert resource.name == "get_sessions" + assert resource.qualified_name == "hierarchy.get_sessions" + assert resource.title == "All Sessions" + assert resource.uri_template == "tmux://sessions{?socket_name}" + assert len(resource.params) == 1 + assert resource.params[0].name == "socket_name" + assert resource.params[0].required is False + + +def test_resource_collector_default_title() -> None: + """_ResourceCollector uses func name as default title.""" + collector = fastmcp_autodoc._ResourceCollector() + collector._current_module = "hierarchy" + + @collector.resource("tmux://sessions") + def get_sessions() -> str: + """List sessions.""" + return "" + + assert collector.resources[0].title == "Get Sessions" + + +# --------------------------------------------------------------------------- +# Real resource collection +# --------------------------------------------------------------------------- + + +def test_collect_real_resources_total_count() -> None: + """All 6 resources should be collected from hierarchy.py.""" + collector = fastmcp_autodoc._ResourceCollector() + collector._current_module = "hierarchy" + + import importlib + + mod = importlib.import_module("libtmux_mcp.resources.hierarchy") + mod.register(collector) + + assert len(collector.resources) == 6 + + +class RealResourceFixture(t.NamedTuple): + """Test fixture for real resource verification.""" + + test_id: str + name: str + uri_template: str + title: str + + +REAL_RESOURCE_FIXTURES: list[RealResourceFixture] = [ + RealResourceFixture( + test_id="get_sessions", + name="get_sessions", + uri_template="tmux://sessions{?socket_name}", + title="All Sessions", + ), + RealResourceFixture( + test_id="get_session", + name="get_session", + uri_template="tmux://sessions/{session_name}{?socket_name}", + title="Session Detail", + ), + RealResourceFixture( + test_id="get_session_windows", + name="get_session_windows", + uri_template="tmux://sessions/{session_name}/windows{?socket_name}", + title="Session Windows", + ), + RealResourceFixture( + test_id="get_window", + name="get_window", + uri_template="tmux://sessions/{session_name}/windows/{window_index}{?socket_name}", + title="Window Detail", + ), + RealResourceFixture( + test_id="get_pane", + name="get_pane", + uri_template="tmux://panes/{pane_id}{?socket_name}", + title="Pane Detail", + ), + RealResourceFixture( + test_id="get_pane_content", + name="get_pane_content", + uri_template="tmux://panes/{pane_id}/content{?socket_name}", + title="Pane Content", + ), +] + + +@pytest.mark.parametrize( + REAL_RESOURCE_FIXTURES[0]._fields, + REAL_RESOURCE_FIXTURES, + ids=[f.test_id for f in REAL_RESOURCE_FIXTURES], +) +def test_collect_real_resource_details( + test_id: str, + name: str, + uri_template: str, + title: str, +) -> None: + """Real resources have correct URI templates and titles.""" + collector = fastmcp_autodoc._ResourceCollector() + collector._current_module = "hierarchy" + + import importlib + + mod = importlib.import_module("libtmux_mcp.resources.hierarchy") + mod.register(collector) + + resources = {r.name: r for r in collector.resources} + resource = resources[name] + assert resource.uri_template == uri_template + assert resource.title == title + + +# --------------------------------------------------------------------------- +# _resource_badge_node +# --------------------------------------------------------------------------- + + +def test_resource_badge_classes() -> None: + """_resource_badge creates badge node with correct CSS classes.""" + badge = fastmcp_autodoc._resource_badge() + assert isinstance(badge, fastmcp_autodoc._resource_badge_node) + assert "sd-bg-info" in badge["classes"] + assert "sd-bg-text-info" in badge["classes"] + assert "sd-sphinx-override" in badge["classes"] + assert "sd-badge" in badge["classes"] + assert badge.astext() == "resource" + + +# --------------------------------------------------------------------------- +# Resource roles +# --------------------------------------------------------------------------- + + +def test_resource_role_creates_placeholder() -> None: + """_resource_role creates _resource_ref_placeholder with show_badge=True.""" + result_nodes, _messages = fastmcp_autodoc._resource_role( + "resource", ":resource:`get-sessions`", "get-sessions", 1, None + ) + assert len(result_nodes) == 1 + node = result_nodes[0] + assert isinstance(node, fastmcp_autodoc._resource_ref_placeholder) + assert node["reftarget"] == "get-sessions" + assert node["show_badge"] is True + + +def test_resourceref_role_creates_placeholder() -> None: + """_resourceref_role creates _resource_ref_placeholder with show_badge=False.""" + result_nodes, _messages = fastmcp_autodoc._resourceref_role( + "resourceref", ":resourceref:`get-sessions`", "get-sessions", 1, None + ) + assert len(result_nodes) == 1 + node = result_nodes[0] + assert isinstance(node, fastmcp_autodoc._resource_ref_placeholder) + assert node["reftarget"] == "get-sessions" + assert node["show_badge"] is False + + +def test_resource_role_normalizes_underscores() -> None: + """_resource_role converts underscores to hyphens in target.""" + result_nodes, _ = fastmcp_autodoc._resource_role( + "resource", ":resource:`get_sessions`", "get_sessions", 1, None + ) + assert result_nodes[0]["reftarget"] == "get-sessions"