quickshell-mcp 2.2.0__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/PKG-INFO +1 -1
  2. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/pyproject.toml +1 -1
  3. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/capabilities/assistant.py +1 -0
  4. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/capabilities/registry.py +12 -2
  5. quickshell_mcp-2.3.0/quickshell_mcp/capabilities/runtime.py +35 -0
  6. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/server.py +106 -0
  7. quickshell_mcp-2.3.0/quickshell_mcp/sources/runtime_profile.py +81 -0
  8. quickshell_mcp-2.3.0/quickshell_mcp/sources/runtime_session.py +287 -0
  9. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/scripts/smoke_test.py +6 -0
  10. quickshell_mcp-2.3.0/tests/fixtures/fake_qs.sh +10 -0
  11. quickshell_mcp-2.3.0/tests/fixtures/runtime-shell/main.qml +48 -0
  12. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_capabilities.py +10 -1
  13. quickshell_mcp-2.3.0/tests/test_runtime.py +201 -0
  14. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/.coderabbit.yaml +0 -0
  15. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  16. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  17. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/.github/ISSUE_TEMPLATE/config_help.yml +0 -0
  18. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  19. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/.github/pull_request_template.md +0 -0
  20. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/.github/workflows/ci.yml +0 -0
  21. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/.github/workflows/semantic-release.yml +0 -0
  22. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/.gitignore +0 -0
  23. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/.releaserc.js +0 -0
  24. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/AGENTS.md +0 -0
  25. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/CLAUDE.md +0 -0
  26. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/CODE_OF_CONDUCT.md +0 -0
  27. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/CONTRIBUTING.md +0 -0
  28. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/Dockerfile +0 -0
  29. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/LICENSE +0 -0
  30. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/README.md +0 -0
  31. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/flake.lock +0 -0
  32. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/flake.nix +0 -0
  33. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/__init__.py +0 -0
  34. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/__main__.py +0 -0
  35. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/caches.py +0 -0
  36. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/capabilities/__init__.py +0 -0
  37. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/capabilities/debugging.py +0 -0
  38. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/capabilities/generation.py +0 -0
  39. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/capabilities/knowledge.py +0 -0
  40. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/capabilities/migration.py +0 -0
  41. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/capabilities/project.py +0 -0
  42. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/capabilities/validation.py +0 -0
  43. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/config.py +0 -0
  44. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/extraction.py +0 -0
  45. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/__init__.py +0 -0
  46. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/assistant.py +0 -0
  47. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/compat.py +0 -0
  48. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/docs.py +0 -0
  49. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/examples.py +0 -0
  50. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/explain_error.py +0 -0
  51. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/find_pattern.py +0 -0
  52. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/generate.py +0 -0
  53. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/implementations.py +0 -0
  54. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/migrate.py +0 -0
  55. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/project.py +0 -0
  56. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/project_intel.py +0 -0
  57. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/project_validate.py +0 -0
  58. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/qt_docs.py +0 -0
  59. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/refactor.py +0 -0
  60. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/search_all.py +0 -0
  61. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/style_match.py +0 -0
  62. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/validate.py +0 -0
  63. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/utils.py +0 -0
  64. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/versions.py +0 -0
  65. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/server.json +0 -0
  66. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/conftest.py +0 -0
  67. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/about.html +0 -0
  68. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/example_readme.md +0 -0
  69. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/examples_contents_root.json +0 -0
  70. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/examples_repo_info.json +0 -0
  71. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/guide_index.html +0 -0
  72. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/hyprland_monitor.html +0 -0
  73. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/impl_caelestia_anchoranim.qml +0 -0
  74. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/impl_caelestia_repo_info.json +0 -0
  75. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/impl_caelestia_tree.json +0 -0
  76. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/impl_dots_audio.qml +0 -0
  77. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/impl_dots_repo_info.json +0 -0
  78. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/impl_dots_tree.json +0 -0
  79. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/impl_noctalia_nbutton.qml +0 -0
  80. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/impl_noctalia_tree.json +0 -0
  81. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/projects/medium/components/ClockWidget.qml +0 -0
  82. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/projects/medium/components/VolumeWidget.qml +0 -0
  83. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/projects/medium/config.toml +0 -0
  84. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/projects/medium/main.qml +0 -0
  85. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/projects/medium/services/VolumeService.qml +0 -0
  86. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/projects/small/config.json +0 -0
  87. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/projects/small/main.qml +0 -0
  88. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/projects/small/util.js +0 -0
  89. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/projects/small/widgets/VolumeWidget.qml +0 -0
  90. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/qml_language.html +0 -0
  91. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/qt_qml_qtquick_rectangle.html +0 -0
  92. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/qt_qtquick_controls_qmlmodule.html +0 -0
  93. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/qt_qtquick_qmlmodule.html +0 -0
  94. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_assistant.py +0 -0
  95. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_code_quality.py +0 -0
  96. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_compat.py +0 -0
  97. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_content_search.py +0 -0
  98. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_diagnostics.py +0 -0
  99. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_disk_cache.py +0 -0
  100. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_errors_retry.py +0 -0
  101. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_examples.py +0 -0
  102. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_explain_error.py +0 -0
  103. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_extraction.py +0 -0
  104. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_find_pattern.py +0 -0
  105. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_generate.py +0 -0
  106. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_implementations.py +0 -0
  107. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_index_search.py +0 -0
  108. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_live.py +0 -0
  109. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_migrate.py +0 -0
  110. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_project.py +0 -0
  111. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_project_generate.py +0 -0
  112. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_project_intel.py +0 -0
  113. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_project_validate.py +0 -0
  114. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_qt_docs.py +0 -0
  115. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_search_all.py +0 -0
  116. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_validate.py +0 -0
  117. {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_versions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quickshell-mcp
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: MCP server that fetches live Quickshell docs from quickshell.org so the model reads real, current docs instead of hallucinating.
5
5
  Project-URL: Homepage, https://github.com/franklinnolasco7/quickshell-mcp
6
6
  Project-URL: Repository, https://github.com/franklinnolasco7/quickshell-mcp
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quickshell-mcp"
3
- version = "2.2.0"
3
+ version = "2.3.0"
4
4
  description = "MCP server that fetches live Quickshell docs from quickshell.org so the model reads real, current docs instead of hallucinating."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -25,4 +25,5 @@ CAPABILITY_DEPENDS_ON = (
25
25
  "migration",
26
26
  "debugging",
27
27
  "project",
28
+ "runtime",
28
29
  )
@@ -25,7 +25,16 @@ from __future__ import annotations
25
25
 
26
26
  from dataclasses import dataclass
27
27
 
28
- from . import assistant, debugging, generation, knowledge, migration, project, validation
28
+ from . import (
29
+ assistant,
30
+ debugging,
31
+ generation,
32
+ knowledge,
33
+ migration,
34
+ project,
35
+ runtime,
36
+ validation,
37
+ )
29
38
 
30
39
  __all__ = [
31
40
  "ALL_CAPABILITIES",
@@ -72,6 +81,7 @@ _CAPABILITY_MODULES = (
72
81
  migration,
73
82
  debugging,
74
83
  project,
84
+ runtime,
75
85
  assistant,
76
86
  )
77
87
 
@@ -93,7 +103,7 @@ PLANNED_CAPABILITIES: dict[str, Capability] = {
93
103
  status="planned",
94
104
  safety_level=_safety_level(name),
95
105
  )
96
- for name in ("runtime", "inspection", "testing", "performance")
106
+ for name in ("inspection", "testing", "performance")
97
107
  }
98
108
 
99
109
  ALL_CAPABILITIES: dict[str, Capability] = {**CAPABILITIES, **PLANNED_CAPABILITIES}
@@ -0,0 +1,35 @@
1
+ """The runtime capability: manage isolated Quickshell runtime sessions.
2
+
3
+ Sessions are launched from explicit runtime profiles with isolated XDG dirs,
4
+ tracked in a registry, and inspectable via status, logs, and ping. The
5
+ capability is mutating: starting, stopping, and resetting sessions alter
6
+ process state and must be explicitly invoked.
7
+
8
+ Depends on: knowledge, project (runtime tools use project detection and
9
+ documentation for diagnostics).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from ..sources.runtime_profile import _RuntimeProfile # noqa: F401
15
+ from ..sources.runtime_session import ( # noqa: F401
16
+ _SESSION_REGISTRY,
17
+ _logs,
18
+ _ping,
19
+ _qs_binary,
20
+ _reset_session,
21
+ _start_session,
22
+ _status_session,
23
+ _stop_session,
24
+ )
25
+
26
+ CAPABILITY_NAME = "runtime"
27
+ CAPABILITY_TOOLS = (
28
+ "quickshell_runtime_start",
29
+ "quickshell_runtime_stop",
30
+ "quickshell_runtime_reset",
31
+ "quickshell_runtime_status",
32
+ "quickshell_runtime_logs",
33
+ "quickshell_runtime_ping",
34
+ )
35
+ CAPABILITY_DEPENDS_ON = ("knowledge", "project")
@@ -117,6 +117,17 @@ from .capabilities.project import ( # noqa: F401
117
117
  _search_project,
118
118
  _validate_project,
119
119
  )
120
+ from .capabilities.runtime import ( # noqa: F401
121
+ _SESSION_REGISTRY,
122
+ _logs,
123
+ _ping,
124
+ _qs_binary,
125
+ _reset_session,
126
+ _RuntimeProfile,
127
+ _start_session,
128
+ _status_session,
129
+ _stop_session,
130
+ )
120
131
  from .capabilities.validation import ( # noqa: F401
121
132
  _api_in_version,
122
133
  _changelog_hits,
@@ -163,6 +174,14 @@ from .versions import ( # noqa: F401
163
174
  mcp = FastMCP("quickshell-mcp")
164
175
 
165
176
 
177
+ def _require_session(session_id: str):
178
+ """Look up a tracked runtime session, raising ValueError if unknown."""
179
+ session = _SESSION_REGISTRY.get(session_id)
180
+ if session is None:
181
+ raise ValueError(f"Unknown runtime session '{session_id}'")
182
+ return session
183
+
184
+
166
185
  @mcp.tool()
167
186
  def quickshell_list_versions(refresh: bool = False) -> dict:
168
187
  """List all Quickshell documentation versions currently published on
@@ -1016,6 +1035,93 @@ def quickshell_project_migrate(project: str, from_version: str, to_version: str)
1016
1035
  return _migrate_project(project, from_version=from_version, to_version=to_version)
1017
1036
 
1018
1037
 
1038
+ @mcp.tool()
1039
+ def quickshell_runtime_start(
1040
+ project: str,
1041
+ entrypoint: str | None = None,
1042
+ config_dir: str | None = None,
1043
+ environment: dict[str, str] | None = None,
1044
+ compositor: str | None = None,
1045
+ arguments: list[str] | None = None,
1046
+ ) -> dict:
1047
+ """Start a managed, isolated Quickshell runtime session for a project.
1048
+
1049
+ Launches ``qs`` with isolated XDG directories so it never touches your
1050
+ real desktop session or other quickshell instances. Returns a session id
1051
+ and tracks the process for later status, logs, ping, stop, and reset.
1052
+ This is a mutating operation: it launches a process."""
1053
+ _record_tool("quickshell_runtime_start")
1054
+ profile = _RuntimeProfile(
1055
+ project_root=project,
1056
+ entrypoint=entrypoint,
1057
+ config_dir=config_dir,
1058
+ environment=environment or {},
1059
+ compositor=compositor,
1060
+ arguments=arguments or [],
1061
+ )
1062
+ return _start_session(profile).to_dict()
1063
+
1064
+
1065
+ @mcp.tool()
1066
+ def quickshell_runtime_stop(session_id: str) -> dict:
1067
+ """Stop a managed runtime session safely (SIGTERM, then SIGKILL on timeout).
1068
+
1069
+ Handles already-exited and orphaned processes; stops only the tracked
1070
+ session's process group, never unrelated user processes. Mutating."""
1071
+ _record_tool("quickshell_runtime_stop")
1072
+ session = _require_session(session_id)
1073
+ _stop_session(session)
1074
+ return session.to_dict()
1075
+
1076
+
1077
+ @mcp.tool()
1078
+ def quickshell_runtime_reset(session_id: str) -> dict:
1079
+ """Reset a managed runtime session to a clean state.
1080
+
1081
+ Stops the current session, cleans up its isolated temp dirs, and starts a
1082
+ fresh session with the same profile under a new session id. Mutating."""
1083
+ _record_tool("quickshell_runtime_reset")
1084
+ session = _require_session(session_id)
1085
+ fresh = _reset_session(session)
1086
+ return fresh.to_dict()
1087
+
1088
+
1089
+ @mcp.tool()
1090
+ def quickshell_runtime_status(session_id: str) -> dict:
1091
+ """Return structured status for a runtime session: session id, running
1092
+ state, PID, startup duration, exit code, and profile identity. Read-only."""
1093
+ _record_tool("quickshell_runtime_status")
1094
+ session = _require_session(session_id)
1095
+ return _status_session(session)
1096
+
1097
+
1098
+ @mcp.tool()
1099
+ def quickshell_runtime_logs(
1100
+ session_id: str,
1101
+ stream: str | None = None,
1102
+ severity: str | None = None,
1103
+ text: str | None = None,
1104
+ limit: int = 200,
1105
+ ) -> dict:
1106
+ """Return structured logs from a runtime session with optional filtering
1107
+ by stream (stdout/stderr), text, and a bounded limit. Read-only."""
1108
+ _record_tool("quickshell_runtime_logs")
1109
+ session = _require_session(session_id)
1110
+ lines = _logs(session, stream=stream, severity=severity, text=text, limit=limit)
1111
+ return {"session_id": session_id, "logs": lines, "count": len(lines)}
1112
+
1113
+
1114
+ @mcp.tool()
1115
+ def quickshell_runtime_ping(session_id: str) -> dict:
1116
+ """Lightweight readiness/health check for a runtime session.
1117
+
1118
+ Distinguishes: process_running, exited (with exit code), or unhealthy.
1119
+ Fast, read-only."""
1120
+ _record_tool("quickshell_runtime_ping")
1121
+ session = _require_session(session_id)
1122
+ return _ping(session)
1123
+
1124
+
1019
1125
  @mcp.tool()
1020
1126
  def quickshell_stats() -> dict:
1021
1127
  """Report session usage stats for this MCP server: per-tool call counts,
@@ -0,0 +1,81 @@
1
+ """Runtime profiles: explicit, inspectable launch configuration for managed
2
+ Quickshell runtime sessions.
3
+
4
+ A profile describes *how* to run a Quickshell project in isolation: the
5
+ project root, entrypoint, config directory, environment overrides, compositor
6
+ or backend, command-line arguments, and optional fixture data. Profiles are
7
+ pure data — nothing here launches processes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+
16
+ @dataclass
17
+ class _RuntimeProfile:
18
+ """Explicit launch configuration for a managed Quickshell session."""
19
+
20
+ project_root: str
21
+ entrypoint: str | None = None
22
+ config_dir: str | None = None
23
+ environment: dict[str, str] = field(default_factory=dict)
24
+ compositor: str | None = None
25
+ arguments: list[str] = field(default_factory=list)
26
+ fixture_data: dict[str, str] = field(default_factory=dict)
27
+
28
+ def resolved_entrypoint(self) -> str:
29
+ """The QML entrypoint: the configured one, or the project's detected
30
+ entrypoint, or ``shell.qml`` / ``main.qml`` under the project root."""
31
+ if self.entrypoint:
32
+ return str(Path(self.entrypoint).expanduser().resolve())
33
+ root = Path(self.project_root).expanduser().resolve()
34
+ for name in ("shell.qml", "main.qml", "config.qml"):
35
+ candidate = root / name
36
+ if candidate.is_file():
37
+ return str(candidate)
38
+ raise ValueError(
39
+ f"No entrypoint found under {root}; pass entrypoint= or add shell.qml/main.qml."
40
+ )
41
+
42
+ def isolated_environment(self, instance_id: str) -> dict[str, str]:
43
+ """Environment for launching the session: inherited variables plus
44
+ profile overrides and isolated XDG dirs for the instance."""
45
+ import os
46
+ import tempfile
47
+
48
+ base = dict(os.environ)
49
+ base.update(self.environment)
50
+ prefix = Path(tempfile.gettempdir()) / f"qs-mcp-{instance_id}"
51
+ # Isolate per-session state so a managed shell never touches the
52
+ # user's real XDG dirs or other quickshell instances.
53
+ base.update(
54
+ {
55
+ "QUICKSHELL_INSTANCE_ID": instance_id,
56
+ "XDG_CONFIG_HOME": str(prefix / "config"),
57
+ "XDG_CACHE_HOME": str(prefix / "cache"),
58
+ "XDG_DATA_HOME": str(prefix / "data"),
59
+ "XDG_STATE_HOME": str(prefix / "state"),
60
+ }
61
+ )
62
+ return base
63
+
64
+ def to_dict(self) -> dict[str, object]:
65
+ root = Path(self.project_root).expanduser().resolve()
66
+ entrypoint: str | None
67
+ if self.entrypoint or root.is_dir():
68
+ try:
69
+ entrypoint = self.resolved_entrypoint()
70
+ except ValueError:
71
+ entrypoint = self.entrypoint
72
+ else:
73
+ entrypoint = self.entrypoint
74
+ return {
75
+ "project_root": self.project_root,
76
+ "entrypoint": entrypoint,
77
+ "config_dir": self.config_dir,
78
+ "compositor": self.compositor,
79
+ "arguments": self.arguments,
80
+ "fixture_data": self.fixture_data,
81
+ }
@@ -0,0 +1,287 @@
1
+ """Managed Quickshell runtime sessions: launch, track, inspect, and stop
2
+ isolated ``qs`` processes backed by a runtime profile.
3
+
4
+ Every session is tracked in the global ``_SESSION_REGISTRY`` by a unique
5
+ session id. Lifecycle operations (start/stop/reset) are mutating; status,
6
+ logs, and ping are read-only. The session ring buffer keeps the last
7
+ :data:`_LOG_BUF_SIZE` log lines.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import os
14
+ import shutil
15
+ import signal
16
+ import subprocess
17
+ import time
18
+ import uuid
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from .runtime_profile import _RuntimeProfile
24
+
25
+ _LOG_BUF_SIZE = 10_000
26
+ _POLL_INTERVAL = 0.1
27
+ _KILL_TIMEOUT = 5.0
28
+
29
+ _SESSION_REGISTRY: dict[str, _RuntimeSession] = {}
30
+
31
+ # Status values
32
+ STATUS_STARTING = "starting"
33
+ STATUS_RUNNING = "running"
34
+ STATUS_EXITED = "exited"
35
+ STATUS_KILLED = "killed"
36
+ STATUS_ERROR = "error"
37
+
38
+
39
+ def _qs_binary() -> str:
40
+ """Path to the ``qs`` executable, or raises ``FileNotFoundError``."""
41
+ path = shutil.which("qs")
42
+ if path is None:
43
+ raise FileNotFoundError(
44
+ "qs binary not found on PATH; install Quickshell to use runtime tools"
45
+ )
46
+ return path
47
+
48
+
49
+ @dataclass
50
+ class _LogLine:
51
+ stream: str # "stdout" | "stderr"
52
+ line: str
53
+ ts: float = field(default_factory=time.time)
54
+
55
+
56
+ @dataclass
57
+ class _RuntimeSession:
58
+ session_id: str
59
+ profile: _RuntimeProfile
60
+ status: str = STATUS_STARTING
61
+ pid: int | None = None
62
+ start_time: float = field(default_factory=time.time)
63
+ exit_code: int | None = None
64
+ kind: str = "managed" # "managed" | "detached"
65
+ log_buffer: list[_LogLine] = field(default_factory=list)
66
+ _process: subprocess.Popen | None = None
67
+
68
+ def append_log(self, stream: str, line: str) -> None:
69
+ self.log_buffer.append(_LogLine(stream=stream, line=line))
70
+ if len(self.log_buffer) > _LOG_BUF_SIZE:
71
+ self.log_buffer = self.log_buffer[-_LOG_BUF_SIZE:]
72
+
73
+ def to_dict(self) -> dict[str, Any]:
74
+ return {
75
+ "session_id": self.session_id,
76
+ "status": self.status,
77
+ "pid": self.pid,
78
+ "start_time": self.start_time,
79
+ "uptime": (
80
+ round(time.time() - self.start_time, 1) if self.status == STATUS_RUNNING else None
81
+ ),
82
+ "exit_code": self.exit_code,
83
+ "kind": self.kind,
84
+ "profile": {
85
+ "project_root": self.profile.project_root,
86
+ "entrypoint": self.profile.entrypoint,
87
+ "compositor": self.profile.compositor,
88
+ },
89
+ }
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Lifecycle
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ def _start_session(profile: _RuntimeProfile) -> _RuntimeSession:
98
+ """Launch a managed Quickshell session from *profile* and register it.
99
+
100
+ Returns a session with status ``running`` on success, or ``error`` if
101
+ the binary is missing or the process fails to start.
102
+ """
103
+ session_id = uuid.uuid4().hex[:12]
104
+ try:
105
+ entrypoint = profile.resolved_entrypoint()
106
+ env = profile.isolated_environment(session_id)
107
+ binary = _qs_binary()
108
+ cmd = [binary, "-p", entrypoint, "--no-daemonize"] + profile.arguments
109
+ proc = subprocess.Popen(
110
+ cmd,
111
+ stdout=subprocess.PIPE,
112
+ stderr=subprocess.PIPE,
113
+ env=env,
114
+ start_new_session=True,
115
+ text=True,
116
+ )
117
+ except (FileNotFoundError, OSError, subprocess.SubprocessError, ValueError) as exc:
118
+ session = _RuntimeSession(
119
+ session_id=session_id,
120
+ profile=profile,
121
+ status=STATUS_ERROR,
122
+ exit_code=-1,
123
+ )
124
+ session.append_log("stderr", f"Failed to launch: {exc}")
125
+ _SESSION_REGISTRY[session_id] = session
126
+ return session
127
+
128
+ session = _RuntimeSession(
129
+ session_id=session_id,
130
+ profile=profile,
131
+ status=STATUS_RUNNING,
132
+ pid=proc.pid,
133
+ _process=proc,
134
+ )
135
+ _SESSION_REGISTRY[session_id] = session
136
+ return session
137
+
138
+
139
+ def _stop_session(session: _RuntimeSession, timeout: float = _KILL_TIMEOUT) -> None:
140
+ """Stop a managed session gracefully (SIGTERM → timeout → SIGKILL).
141
+
142
+ Handles already-exited and orphaned processes safely.
143
+ """
144
+ if session.status in (STATUS_EXITED, STATUS_KILLED, STATUS_ERROR):
145
+ return
146
+ pid = session.pid
147
+ if pid is None:
148
+ session.status = STATUS_ERROR
149
+ return
150
+
151
+ try:
152
+ # Send SIGTERM to the whole process group (negative pid).
153
+ os.killpg(pid, signal.SIGTERM)
154
+ waited = _wait_with_timeout(pid, timeout)
155
+ if waited is None:
156
+ os.killpg(pid, signal.SIGKILL)
157
+ _wait_with_timeout(pid, 2.0)
158
+ session.status = STATUS_KILLED
159
+ else:
160
+ session.status = STATUS_EXITED
161
+ session.exit_code = waited
162
+ except ProcessLookupError:
163
+ session.status = STATUS_EXITED
164
+ session.exit_code = 0
165
+ except PermissionError:
166
+ session.status = STATUS_ERROR
167
+
168
+ _drain_logs(session)
169
+ session._process = None
170
+
171
+
172
+ def _reset_session(session: _RuntimeSession) -> _RuntimeSession:
173
+ """Stop the existing session and start a fresh one with the same profile.
174
+
175
+ A new session id is allocated; the old session is removed from the
176
+ registry. Stale processes, temp files, and sockets are cleaned up.
177
+ """
178
+ _stop_session(session)
179
+ _cleanup_temp(session)
180
+ old_id = session.session_id
181
+ _SESSION_REGISTRY.pop(old_id, None)
182
+ return _start_session(session.profile)
183
+
184
+
185
+ def _status_session(session: _RuntimeSession) -> dict[str, Any]:
186
+ """Return structured status for a session, refreshing the process state."""
187
+ _refresh_status(session)
188
+ return session.to_dict()
189
+
190
+
191
+ def _logs(
192
+ session: _RuntimeSession,
193
+ *,
194
+ stream: str | None = None,
195
+ severity: str | None = None,
196
+ text: str | None = None,
197
+ limit: int = 200,
198
+ ) -> list[dict[str, Any]]:
199
+ """Return structured log lines from the session's ring buffer."""
200
+ lines = list(session.log_buffer)
201
+ if stream:
202
+ lines = [line for line in lines if line.stream == stream]
203
+ if text:
204
+ text_lower = text.lower()
205
+ lines = [line for line in lines if text_lower in line.line.lower()]
206
+ return [{"ts": line.ts, "stream": line.stream, "line": line.line} for line in lines[-limit:]]
207
+
208
+
209
+ def _ping(session: _RuntimeSession) -> dict[str, str]:
210
+ """Lightweight health check. Returns one of:
211
+
212
+ * ``process_running`` — the process is alive
213
+ * ``exited`` — the process has exited
214
+ * ``unhealthy`` — the session is in an error state
215
+ """
216
+ _refresh_status(session)
217
+ if session.status == STATUS_RUNNING:
218
+ return {"status": "process_running"}
219
+ if session.status in (STATUS_EXITED, STATUS_KILLED):
220
+ return {"status": "exited", "exit_code": str(session.exit_code)}
221
+ return {"status": "unhealthy", "detail": session.status}
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # Internal helpers
226
+ # ---------------------------------------------------------------------------
227
+
228
+
229
+ def _wait_with_timeout(pid: int, timeout: float) -> int | None:
230
+ """Wait for *pid* to exit, returning the exit code or None on timeout."""
231
+ deadline = time.time() + timeout
232
+ while time.time() < deadline:
233
+ try:
234
+ pid_out, status = os.waitpid(pid, os.WNOHANG)
235
+ if pid_out == pid:
236
+ if os.WIFEXITED(status):
237
+ return os.WEXITSTATUS(status)
238
+ if os.WIFSIGNALED(status):
239
+ return -os.WTERMSIG(status)
240
+ return status
241
+ except ChildProcessError:
242
+ return None
243
+ time.sleep(_POLL_INTERVAL)
244
+ return None
245
+
246
+
247
+ def _drain_logs(session: _RuntimeSession) -> None:
248
+ """Read any remaining stdout/stderr from the finished process."""
249
+ proc = session._process
250
+ if proc is None:
251
+ return
252
+ for stream_name in ("stdout", "stderr"):
253
+ handle = getattr(proc, stream_name, None)
254
+ if handle is None:
255
+ continue
256
+ for line in handle.readlines():
257
+ session.append_log(stream_name, line.rstrip("\n"))
258
+
259
+
260
+ def _refresh_status(session: _RuntimeSession) -> None:
261
+ """Update session status from the actual process state."""
262
+ if session.status in (STATUS_EXITED, STATUS_KILLED, STATUS_ERROR):
263
+ return
264
+ pid = session.pid
265
+ if pid is None:
266
+ session.status = STATUS_ERROR
267
+ return
268
+ try:
269
+ os.kill(pid, 0)
270
+ session.status = STATUS_RUNNING
271
+ except ProcessLookupError:
272
+ _drain_logs(session)
273
+ session.status = STATUS_EXITED
274
+ session.exit_code = 0
275
+ except PermissionError:
276
+ session.status = STATUS_ERROR
277
+
278
+
279
+ def _cleanup_temp(session: _RuntimeSession) -> None:
280
+ """Remove the isolated temp directories for a session."""
281
+ import tempfile
282
+
283
+ for var in ("XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"):
284
+ env = session.profile.environment.get(var) or ""
285
+ if env.startswith(tempfile.gettempdir()):
286
+ with contextlib.suppress(OSError):
287
+ Path(env).rmdir() # only removes empty dirs, not recursive
@@ -123,6 +123,12 @@ def main() -> int:
123
123
  "quickshell_refactor",
124
124
  "quickshell_apply_patch",
125
125
  "quickshell_style_match",
126
+ "quickshell_runtime_start",
127
+ "quickshell_runtime_stop",
128
+ "quickshell_runtime_reset",
129
+ "quickshell_runtime_status",
130
+ "quickshell_runtime_logs",
131
+ "quickshell_runtime_ping",
126
132
  }
127
133
  missing = expected - names
128
134
  assert not missing, f"missing tools: {missing}"
@@ -0,0 +1,10 @@
1
+ #!/bin/sh
2
+ # Fake qs binary for offline runtime tests.
3
+ # Simulates a running quickshell instance: prints PID, waits for SIGTERM,
4
+ # cleans up, and exits.
5
+ echo "fake-qs: starting instance"
6
+ echo "fake-qs: instance id = test"
7
+ while true; do
8
+ sleep 0.1
9
+ echo "tick" >&2
10
+ done
@@ -0,0 +1,48 @@
1
+ import Quickshell
2
+ import Quickshell.Io
3
+ import QtQuick
4
+
5
+ // Minimal managed-runtime fixture shell. Exposes an IpcHandler target
6
+ // "inspector" so runtime tools can introspect and drive the UI over `qs ipc`.
7
+ PanelWindow {
8
+ id: root
9
+ width: 200
10
+ height: 60
11
+ color: "#1e1e2e"
12
+
13
+ property string greeting: "hello"
14
+ property int counter: 0
15
+
16
+ Text {
17
+ id: label
18
+ anchors.centerIn: parent
19
+ text: root.greeting + " #" + root.counter
20
+ color: "#cdd6f4"
21
+ }
22
+
23
+ IpcHandler {
24
+ target: "inspector"
25
+
26
+ function getProperty(name: string): string {
27
+ return String(root[name]);
28
+ }
29
+
30
+ function setProperty(name: string, value: string): void {
31
+ root[name] = value;
32
+ }
33
+
34
+ function getGreeting(): string {
35
+ return root.greeting;
36
+ }
37
+
38
+ function getCounter(): int {
39
+ return root.counter;
40
+ }
41
+
42
+ function bumpCounter(): void {
43
+ root.counter += 1;
44
+ }
45
+
46
+ signal counterChanged(value: int);
47
+ }
48
+ }
@@ -61,6 +61,12 @@ EXPECTED_TOOLS = {
61
61
  "quickshell_refactor",
62
62
  "quickshell_apply_patch",
63
63
  "quickshell_style_match",
64
+ "quickshell_runtime_start",
65
+ "quickshell_runtime_stop",
66
+ "quickshell_runtime_reset",
67
+ "quickshell_runtime_status",
68
+ "quickshell_runtime_logs",
69
+ "quickshell_runtime_ping",
64
70
  }
65
71
 
66
72
  # Tools that report session telemetry / live in server.py, not a domain capability.
@@ -138,7 +144,10 @@ def test_planned_capabilities_are_metadata_only():
138
144
 
139
145
  def test_implemented_capability_safety_levels():
140
146
  for name, cap in registry.CAPABILITIES.items():
141
- assert cap.safety_level == "read-only", f"{name} should be read-only"
147
+ if name in ("runtime", "testing"):
148
+ assert cap.safety_level == "mutating", f"{name} should be mutating"
149
+ else:
150
+ assert cap.safety_level == "read-only", f"{name} should be read-only"
142
151
 
143
152
 
144
153
  def test_planned_capability_safety_levels():