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.
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/PKG-INFO +1 -1
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/pyproject.toml +1 -1
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/capabilities/assistant.py +1 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/capabilities/registry.py +12 -2
- quickshell_mcp-2.3.0/quickshell_mcp/capabilities/runtime.py +35 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/server.py +106 -0
- quickshell_mcp-2.3.0/quickshell_mcp/sources/runtime_profile.py +81 -0
- quickshell_mcp-2.3.0/quickshell_mcp/sources/runtime_session.py +287 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/scripts/smoke_test.py +6 -0
- quickshell_mcp-2.3.0/tests/fixtures/fake_qs.sh +10 -0
- quickshell_mcp-2.3.0/tests/fixtures/runtime-shell/main.qml +48 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_capabilities.py +10 -1
- quickshell_mcp-2.3.0/tests/test_runtime.py +201 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/.coderabbit.yaml +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/.github/ISSUE_TEMPLATE/config_help.yml +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/.github/pull_request_template.md +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/.github/workflows/ci.yml +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/.github/workflows/semantic-release.yml +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/.gitignore +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/.releaserc.js +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/AGENTS.md +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/CLAUDE.md +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/CODE_OF_CONDUCT.md +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/CONTRIBUTING.md +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/Dockerfile +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/LICENSE +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/README.md +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/flake.lock +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/flake.nix +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/__init__.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/__main__.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/caches.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/capabilities/__init__.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/capabilities/debugging.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/capabilities/generation.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/capabilities/knowledge.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/capabilities/migration.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/capabilities/project.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/capabilities/validation.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/config.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/extraction.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/__init__.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/assistant.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/compat.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/docs.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/examples.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/explain_error.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/find_pattern.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/generate.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/implementations.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/migrate.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/project.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/project_intel.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/project_validate.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/qt_docs.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/refactor.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/search_all.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/style_match.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/sources/validate.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/utils.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/quickshell_mcp/versions.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/server.json +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/conftest.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/about.html +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/example_readme.md +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/examples_contents_root.json +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/examples_repo_info.json +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/guide_index.html +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/hyprland_monitor.html +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/impl_caelestia_anchoranim.qml +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/impl_caelestia_repo_info.json +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/impl_caelestia_tree.json +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/impl_dots_audio.qml +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/impl_dots_repo_info.json +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/impl_dots_tree.json +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/impl_noctalia_nbutton.qml +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/impl_noctalia_tree.json +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/projects/medium/components/ClockWidget.qml +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/projects/medium/components/VolumeWidget.qml +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/projects/medium/config.toml +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/projects/medium/main.qml +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/projects/medium/services/VolumeService.qml +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/projects/small/config.json +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/projects/small/main.qml +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/projects/small/util.js +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/projects/small/widgets/VolumeWidget.qml +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/qml_language.html +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/qt_qml_qtquick_rectangle.html +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/qt_qtquick_controls_qmlmodule.html +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/fixtures/qt_qtquick_qmlmodule.html +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_assistant.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_code_quality.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_compat.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_content_search.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_diagnostics.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_disk_cache.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_errors_retry.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_examples.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_explain_error.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_extraction.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_find_pattern.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_generate.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_implementations.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_index_search.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_live.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_migrate.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_project.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_project_generate.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_project_intel.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_project_validate.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_qt_docs.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_search_all.py +0 -0
- {quickshell_mcp-2.2.0 → quickshell_mcp-2.3.0}/tests/test_validate.py +0 -0
- {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.
|
|
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.
|
|
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,7 +25,16 @@ from __future__ import annotations
|
|
|
25
25
|
|
|
26
26
|
from dataclasses import dataclass
|
|
27
27
|
|
|
28
|
-
from . import
|
|
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 ("
|
|
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
|
-
|
|
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():
|