@josephyan/qingflow-app-builder-mcp 0.1.0-beta.9

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 (55) hide show
  1. package/README.md +21 -0
  2. package/docs/local-agent-install.md +228 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow-app-builder-mcp.mjs +7 -0
  5. package/npm/lib/runtime.mjs +146 -0
  6. package/npm/scripts/postinstall.mjs +12 -0
  7. package/package.json +33 -0
  8. package/pyproject.toml +64 -0
  9. package/qingflow-app-builder-mcp +15 -0
  10. package/src/qingflow_mcp/__init__.py +5 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +336 -0
  13. package/src/qingflow_mcp/config.py +182 -0
  14. package/src/qingflow_mcp/errors.py +66 -0
  15. package/src/qingflow_mcp/json_types.py +18 -0
  16. package/src/qingflow_mcp/list_type_labels.py +52 -0
  17. package/src/qingflow_mcp/server.py +70 -0
  18. package/src/qingflow_mcp/server_app_builder.py +352 -0
  19. package/src/qingflow_mcp/server_app_user.py +334 -0
  20. package/src/qingflow_mcp/session_store.py +249 -0
  21. package/src/qingflow_mcp/solution/__init__.py +6 -0
  22. package/src/qingflow_mcp/solution/build_assembly_store.py +137 -0
  23. package/src/qingflow_mcp/solution/compiler/__init__.py +265 -0
  24. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  25. package/src/qingflow_mcp/solution/compiler/form_compiler.py +456 -0
  26. package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
  27. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  28. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  29. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  30. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  31. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +134 -0
  32. package/src/qingflow_mcp/solution/design_session.py +222 -0
  33. package/src/qingflow_mcp/solution/design_store.py +100 -0
  34. package/src/qingflow_mcp/solution/executor.py +2065 -0
  35. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  36. package/src/qingflow_mcp/solution/run_store.py +221 -0
  37. package/src/qingflow_mcp/solution/spec_models.py +853 -0
  38. package/src/qingflow_mcp/tools/__init__.py +1 -0
  39. package/src/qingflow_mcp/tools/app_tools.py +406 -0
  40. package/src/qingflow_mcp/tools/approval_tools.py +498 -0
  41. package/src/qingflow_mcp/tools/auth_tools.py +514 -0
  42. package/src/qingflow_mcp/tools/base.py +81 -0
  43. package/src/qingflow_mcp/tools/directory_tools.py +476 -0
  44. package/src/qingflow_mcp/tools/file_tools.py +375 -0
  45. package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
  46. package/src/qingflow_mcp/tools/package_tools.py +142 -0
  47. package/src/qingflow_mcp/tools/portal_tools.py +100 -0
  48. package/src/qingflow_mcp/tools/qingbi_report_tools.py +235 -0
  49. package/src/qingflow_mcp/tools/record_tools.py +4307 -0
  50. package/src/qingflow_mcp/tools/role_tools.py +94 -0
  51. package/src/qingflow_mcp/tools/solution_tools.py +2680 -0
  52. package/src/qingflow_mcp/tools/task_tools.py +692 -0
  53. package/src/qingflow_mcp/tools/view_tools.py +280 -0
  54. package/src/qingflow_mcp/tools/workflow_tools.py +238 -0
  55. package/src/qingflow_mcp/tools/workspace_tools.py +170 -0
@@ -0,0 +1,334 @@
1
+ from __future__ import annotations
2
+
3
+ from mcp.server.fastmcp import FastMCP
4
+
5
+ from .backend_client import BackendClient
6
+ from .config import DEFAULT_PROFILE
7
+ from .session_store import SessionStore
8
+ from .tools.approval_tools import ApprovalTools
9
+ from .tools.auth_tools import AuthTools
10
+ from .tools.directory_tools import DirectoryTools
11
+ from .tools.file_tools import FileTools
12
+ from .tools.record_tools import RecordTools
13
+ from .tools.task_tools import TaskTools
14
+ from .tools.workspace_tools import WorkspaceTools
15
+
16
+
17
+ def build_user_server() -> FastMCP:
18
+ server = FastMCP(
19
+ "Qingflow App User MCP",
20
+ instructions=(
21
+ "Use this server for Qingflow record queries, record writes, task center operations, "
22
+ "directory lookups, and approval actions. Avoid builder-side app or schema changes here."
23
+ ),
24
+ )
25
+ sessions = SessionStore()
26
+ backend = BackendClient()
27
+ auth = AuthTools(sessions, backend)
28
+ workspace = WorkspaceTools(sessions, backend)
29
+ files = FileTools(sessions, backend)
30
+ approvals = ApprovalTools(sessions, backend)
31
+ tasks = TaskTools(sessions, backend)
32
+
33
+ @server.tool()
34
+ def auth_login(
35
+ profile: str = DEFAULT_PROFILE,
36
+ base_url: str | None = None,
37
+ qf_version: str | None = None,
38
+ email: str = "",
39
+ password: str = "",
40
+ persist: bool = True,
41
+ ) -> dict:
42
+ return auth.auth_login(
43
+ profile=profile,
44
+ base_url=base_url,
45
+ qf_version=qf_version,
46
+ email=email,
47
+ password=password,
48
+ persist=persist,
49
+ )
50
+
51
+ @server.tool()
52
+ def auth_use_token(
53
+ profile: str = DEFAULT_PROFILE,
54
+ base_url: str | None = None,
55
+ qf_version: str | None = None,
56
+ token: str = "",
57
+ ws_id: int | None = None,
58
+ persist: bool = False,
59
+ ) -> dict:
60
+ return auth.auth_use_token(
61
+ profile=profile,
62
+ base_url=base_url,
63
+ qf_version=qf_version,
64
+ token=token,
65
+ ws_id=ws_id,
66
+ persist=persist,
67
+ )
68
+
69
+ @server.tool()
70
+ def auth_whoami(profile: str = DEFAULT_PROFILE) -> dict:
71
+ return auth.auth_whoami(profile=profile)
72
+
73
+ @server.tool()
74
+ def auth_logout(profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict:
75
+ return auth.auth_logout(profile=profile, forget_persisted=forget_persisted)
76
+
77
+ @server.tool()
78
+ def workspace_list(
79
+ profile: str = DEFAULT_PROFILE,
80
+ page_num: int = 1,
81
+ page_size: int = 20,
82
+ include_external: bool = False,
83
+ ) -> dict:
84
+ return workspace.workspace_list(
85
+ profile=profile,
86
+ page_num=page_num,
87
+ page_size=page_size,
88
+ include_external=include_external,
89
+ )
90
+
91
+ @server.tool()
92
+ def workspace_select(profile: str = DEFAULT_PROFILE, ws_id: int = 0) -> dict:
93
+ return workspace.workspace_select(profile=profile, ws_id=ws_id)
94
+
95
+ @server.tool()
96
+ def file_get_upload_info(
97
+ profile: str = DEFAULT_PROFILE,
98
+ upload_kind: str = "attachment",
99
+ file_name: str = "",
100
+ file_size: int = 0,
101
+ upload_mark: str | None = None,
102
+ content_type: str | None = None,
103
+ bucket_type: str | None = None,
104
+ path_id: int | None = None,
105
+ file_related_url: str | None = None,
106
+ ) -> dict:
107
+ return files.file_get_upload_info(
108
+ profile=profile,
109
+ upload_kind=upload_kind,
110
+ file_name=file_name,
111
+ file_size=file_size,
112
+ upload_mark=upload_mark,
113
+ content_type=content_type,
114
+ bucket_type=bucket_type,
115
+ path_id=path_id,
116
+ file_related_url=file_related_url,
117
+ )
118
+
119
+ @server.tool()
120
+ def file_upload_local(
121
+ profile: str = DEFAULT_PROFILE,
122
+ upload_kind: str = "attachment",
123
+ file_path: str = "",
124
+ upload_mark: str | None = None,
125
+ content_type: str | None = None,
126
+ bucket_type: str | None = None,
127
+ path_id: int | None = None,
128
+ file_related_url: str | None = None,
129
+ ) -> dict:
130
+ return files.file_upload_local(
131
+ profile=profile,
132
+ upload_kind=upload_kind,
133
+ file_path=file_path,
134
+ upload_mark=upload_mark,
135
+ content_type=content_type,
136
+ bucket_type=bucket_type,
137
+ path_id=path_id,
138
+ file_related_url=file_related_url,
139
+ )
140
+
141
+ RecordTools(sessions, backend).register(server)
142
+ DirectoryTools(sessions, backend).register(server)
143
+
144
+ @server.tool()
145
+ def record_comment_add(
146
+ profile: str = DEFAULT_PROFILE,
147
+ app_key: str = "",
148
+ apply_id: int = 0,
149
+ payload: dict | None = None,
150
+ ) -> dict:
151
+ return approvals.record_comment_add(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
152
+
153
+ @server.tool()
154
+ def record_comment_list(
155
+ profile: str = DEFAULT_PROFILE,
156
+ app_key: str = "",
157
+ apply_id: int = 0,
158
+ page_size: int = 20,
159
+ list_type: int | None = None,
160
+ page_num: int | None = 1,
161
+ ) -> dict:
162
+ return approvals.record_comment_list(
163
+ profile=profile,
164
+ app_key=app_key,
165
+ apply_id=apply_id,
166
+ page_size=page_size,
167
+ list_type=list_type,
168
+ page_num=page_num,
169
+ )
170
+
171
+ @server.tool()
172
+ def record_comment_mention_candidates(
173
+ profile: str = DEFAULT_PROFILE,
174
+ app_key: str = "",
175
+ apply_id: int = 0,
176
+ page_size: int = 20,
177
+ page_num: int = 1,
178
+ list_type: int | None = None,
179
+ keyword: str | None = None,
180
+ ) -> dict:
181
+ return approvals.record_comment_mention_candidates(
182
+ profile=profile,
183
+ app_key=app_key,
184
+ apply_id=apply_id,
185
+ page_size=page_size,
186
+ page_num=page_num,
187
+ list_type=list_type,
188
+ keyword=keyword,
189
+ )
190
+
191
+ @server.tool()
192
+ def record_comment_mark_read(profile: str = DEFAULT_PROFILE, app_key: str = "", apply_id: int = 0) -> dict:
193
+ return approvals.record_comment_mark_read(profile=profile, app_key=app_key, apply_id=apply_id)
194
+
195
+ @server.tool()
196
+ def record_comment_stats(profile: str = DEFAULT_PROFILE, app_key: str = "", apply_id: int = 0) -> dict:
197
+ return approvals.record_comment_stats(profile=profile, app_key=app_key, apply_id=apply_id)
198
+
199
+ @server.tool()
200
+ def task_list(
201
+ profile: str = DEFAULT_PROFILE,
202
+ type: int = 1,
203
+ process_status: int = 1,
204
+ app_key: str | None = None,
205
+ node_id: int | None = None,
206
+ search_key: str | None = None,
207
+ page_num: int = 1,
208
+ page_size: int = 20,
209
+ create_time_asc: bool | None = None,
210
+ ) -> dict:
211
+ return tasks.task_list(
212
+ profile=profile,
213
+ type=type,
214
+ process_status=process_status,
215
+ app_key=app_key,
216
+ node_id=node_id,
217
+ search_key=search_key,
218
+ page_num=page_num,
219
+ page_size=page_size,
220
+ create_time_asc=create_time_asc,
221
+ )
222
+
223
+ @server.tool()
224
+ def task_list_grouped(
225
+ profile: str = DEFAULT_PROFILE,
226
+ type: int = 1,
227
+ process_status: int = 1,
228
+ app_key: str | None = None,
229
+ node_id: int | None = None,
230
+ search_key: str | None = None,
231
+ page_num: int = 1,
232
+ page_size: int = 20,
233
+ ) -> dict:
234
+ return tasks.task_list_grouped(
235
+ profile=profile,
236
+ type=type,
237
+ process_status=process_status,
238
+ app_key=app_key,
239
+ node_id=node_id,
240
+ search_key=search_key,
241
+ page_num=page_num,
242
+ page_size=page_size,
243
+ )
244
+
245
+ @server.tool()
246
+ def task_statistics(profile: str = DEFAULT_PROFILE, app_key: str | None = None) -> dict:
247
+ return tasks.task_statistics(profile=profile, app_key=app_key)
248
+
249
+ @server.tool()
250
+ def task_urge(profile: str = DEFAULT_PROFILE, app_key: str = "", row_record_id: int = 0) -> dict:
251
+ return tasks.task_urge(profile=profile, app_key=app_key, row_record_id=row_record_id)
252
+
253
+ @server.tool(description=approvals._high_risk_tool_description(operation="approve", target="workflow task"))
254
+ def task_approve(
255
+ profile: str = DEFAULT_PROFILE,
256
+ app_key: str = "",
257
+ apply_id: int = 0,
258
+ payload: dict | None = None,
259
+ ) -> dict:
260
+ return approvals.record_approve(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
261
+
262
+ @server.tool(description=approvals._high_risk_tool_description(operation="reject", target="workflow task"))
263
+ def task_reject(
264
+ profile: str = DEFAULT_PROFILE,
265
+ app_key: str = "",
266
+ apply_id: int = 0,
267
+ payload: dict | None = None,
268
+ ) -> dict:
269
+ return approvals.record_reject(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
270
+
271
+ @server.tool()
272
+ def task_rollback_candidates(
273
+ profile: str = DEFAULT_PROFILE,
274
+ app_key: str = "",
275
+ apply_id: int = 0,
276
+ audit_node_id: int = 0,
277
+ ) -> dict:
278
+ return approvals.record_rollback_candidates(
279
+ profile=profile,
280
+ app_key=app_key,
281
+ apply_id=apply_id,
282
+ audit_node_id=audit_node_id,
283
+ )
284
+
285
+ @server.tool()
286
+ def task_rollback(
287
+ profile: str = DEFAULT_PROFILE,
288
+ app_key: str = "",
289
+ apply_id: int = 0,
290
+ payload: dict | None = None,
291
+ ) -> dict:
292
+ return approvals.record_rollback(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
293
+
294
+ @server.tool()
295
+ def task_transfer_candidates(
296
+ profile: str = DEFAULT_PROFILE,
297
+ app_key: str = "",
298
+ apply_id: int = 0,
299
+ page_size: int = 20,
300
+ page_num: int = 1,
301
+ audit_node_id: int = 0,
302
+ keyword: str | None = None,
303
+ ) -> dict:
304
+ return approvals.record_transfer_candidates(
305
+ profile=profile,
306
+ app_key=app_key,
307
+ apply_id=apply_id,
308
+ page_size=page_size,
309
+ page_num=page_num,
310
+ audit_node_id=audit_node_id,
311
+ keyword=keyword,
312
+ )
313
+
314
+ @server.tool()
315
+ def task_transfer(
316
+ profile: str = DEFAULT_PROFILE,
317
+ app_key: str = "",
318
+ apply_id: int = 0,
319
+ payload: dict | None = None,
320
+ ) -> dict:
321
+ return approvals.record_transfer(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
322
+
323
+ return server
324
+
325
+
326
+ mcp = build_user_server()
327
+
328
+
329
+ def main() -> None:
330
+ mcp.run()
331
+
332
+
333
+ if __name__ == "__main__":
334
+ main()
@@ -0,0 +1,249 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict, dataclass
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ try:
9
+ import keyring
10
+ except ImportError:
11
+ keyring = None
12
+
13
+ from .config import get_profiles_path, normalize_base_url
14
+ from .json_types import JSONObject, KeyringBackend
15
+
16
+
17
+ KEYRING_SERVICE_NAME = "qingflow-mcp"
18
+
19
+
20
+ def _utcnow() -> str:
21
+ return datetime.now(timezone.utc).isoformat()
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class SessionProfile:
26
+ profile: str
27
+ base_url: str
28
+ qf_version: str | None
29
+ qf_version_source: str | None
30
+ uid: int
31
+ email: str | None
32
+ nick_name: str | None
33
+ selected_ws_id: int | None
34
+ selected_ws_name: str | None
35
+ persisted: bool
36
+ created_at: str
37
+ updated_at: str
38
+
39
+ @classmethod
40
+ def from_dict(cls, value: JSONObject) -> "SessionProfile":
41
+ return cls(
42
+ profile=value["profile"],
43
+ base_url=value["base_url"],
44
+ qf_version=value.get("qf_version"),
45
+ qf_version_source=value.get("qf_version_source"),
46
+ uid=value["uid"],
47
+ email=value.get("email"),
48
+ nick_name=value.get("nick_name"),
49
+ selected_ws_id=value.get("selected_ws_id"),
50
+ selected_ws_name=value.get("selected_ws_name"),
51
+ persisted=bool(value.get("persisted", False)),
52
+ created_at=value.get("created_at", _utcnow()),
53
+ updated_at=value.get("updated_at", _utcnow()),
54
+ )
55
+
56
+
57
+ @dataclass(slots=True)
58
+ class BackendSession:
59
+ token: str
60
+ login_token: str | None
61
+ profile: str
62
+ base_url: str
63
+ qf_version: str | None
64
+ qf_version_source: str | None = None
65
+
66
+
67
+ class SessionStore:
68
+ def __init__(self, base_dir: Path | None = None, keyring_backend: KeyringBackend | None = None) -> None:
69
+ profiles_path = get_profiles_path() if base_dir is None else Path(base_dir) / "profiles.json"
70
+ self._profiles_path = profiles_path
71
+ self._profiles_path.parent.mkdir(parents=True, exist_ok=True)
72
+ self._keyring = keyring_backend if keyring_backend is not None else keyring
73
+ self._memory_sessions: dict[str, BackendSession] = {}
74
+ self._logged_out_profiles: set[str] = set()
75
+
76
+ def save_session(
77
+ self,
78
+ *,
79
+ profile: str,
80
+ base_url: str,
81
+ qf_version: str | None,
82
+ qf_version_source: str | None = None,
83
+ token: str,
84
+ login_token: str | None,
85
+ uid: int,
86
+ email: str | None,
87
+ nick_name: str | None,
88
+ persist: bool,
89
+ ) -> SessionProfile:
90
+ now = _utcnow()
91
+ previous = self.get_profile(profile)
92
+ persisted = False
93
+ if persist:
94
+ persisted = self._set_secret(self._token_key(profile), token)
95
+ if login_token:
96
+ self._set_secret(self._login_token_key(profile), login_token)
97
+ else:
98
+ self._delete_secret(self._login_token_key(profile))
99
+ else:
100
+ self._delete_secret(self._token_key(profile))
101
+ self._delete_secret(self._login_token_key(profile))
102
+ session_profile = SessionProfile(
103
+ profile=profile,
104
+ base_url=normalize_base_url(base_url) or base_url,
105
+ qf_version=(str(qf_version).strip() or None) if qf_version is not None else None,
106
+ qf_version_source=(str(qf_version_source).strip() or None) if qf_version_source is not None else None,
107
+ uid=uid,
108
+ email=email,
109
+ nick_name=nick_name,
110
+ selected_ws_id=None,
111
+ selected_ws_name=None,
112
+ persisted=persisted,
113
+ created_at=previous.created_at if previous else now,
114
+ updated_at=now,
115
+ )
116
+ self._memory_sessions[profile] = BackendSession(
117
+ token=token,
118
+ login_token=login_token,
119
+ profile=profile,
120
+ base_url=session_profile.base_url,
121
+ qf_version=session_profile.qf_version,
122
+ qf_version_source=session_profile.qf_version_source,
123
+ )
124
+ self._logged_out_profiles.discard(profile)
125
+ self._upsert_profile(session_profile)
126
+ return session_profile
127
+
128
+ def get_profile(self, profile: str) -> SessionProfile | None:
129
+ payload = self._load_profiles()
130
+ raw_profile = payload.get("profiles", {}).get(profile)
131
+ if not raw_profile:
132
+ return None
133
+ return SessionProfile.from_dict(raw_profile)
134
+
135
+ def get_backend_session(self, profile: str) -> BackendSession | None:
136
+ if profile in self._logged_out_profiles:
137
+ return None
138
+ memory_session = self._memory_sessions.get(profile)
139
+ if memory_session:
140
+ return memory_session
141
+ session_profile = self.get_profile(profile)
142
+ if not session_profile or not session_profile.persisted:
143
+ return None
144
+ token = self._get_secret(self._token_key(profile))
145
+ if not token:
146
+ return None
147
+ backend_session = BackendSession(
148
+ token=token,
149
+ login_token=self._get_secret(self._login_token_key(profile)),
150
+ profile=profile,
151
+ base_url=session_profile.base_url,
152
+ qf_version=session_profile.qf_version,
153
+ qf_version_source=session_profile.qf_version_source,
154
+ )
155
+ self._memory_sessions[profile] = backend_session
156
+ return backend_session
157
+
158
+ def select_workspace(self, profile: str, ws_id: int, ws_name: str | None) -> SessionProfile:
159
+ session_profile = self.get_profile(profile)
160
+ if session_profile is None:
161
+ raise KeyError(profile)
162
+ session_profile.selected_ws_id = ws_id
163
+ session_profile.selected_ws_name = ws_name
164
+ session_profile.updated_at = _utcnow()
165
+ self._upsert_profile(session_profile)
166
+ return session_profile
167
+
168
+ def update_route(self, profile: str, *, qf_version: str | None, qf_version_source: str | None) -> SessionProfile:
169
+ session_profile = self.get_profile(profile)
170
+ if session_profile is None:
171
+ raise KeyError(profile)
172
+ session_profile.qf_version = (str(qf_version).strip() or None) if qf_version is not None else None
173
+ session_profile.qf_version_source = (str(qf_version_source).strip() or None) if qf_version_source is not None else None
174
+ session_profile.updated_at = _utcnow()
175
+ self._upsert_profile(session_profile)
176
+ backend_session = self._memory_sessions.get(profile)
177
+ if backend_session is not None:
178
+ backend_session.qf_version = session_profile.qf_version
179
+ backend_session.qf_version_source = session_profile.qf_version_source
180
+ return session_profile
181
+
182
+ def logout(self, profile: str, forget_persisted: bool = False) -> None:
183
+ self._memory_sessions.pop(profile, None)
184
+ if forget_persisted:
185
+ self.invalidate(profile)
186
+ return
187
+ if self.get_profile(profile):
188
+ self._logged_out_profiles.add(profile)
189
+
190
+ def invalidate(self, profile: str) -> None:
191
+ self._memory_sessions.pop(profile, None)
192
+ self._logged_out_profiles.discard(profile)
193
+ self._delete_secret(self._token_key(profile))
194
+ self._delete_secret(self._login_token_key(profile))
195
+ payload = self._load_profiles()
196
+ profiles = payload.get("profiles", {})
197
+ if profile in profiles:
198
+ profiles.pop(profile)
199
+ self._save_profiles(payload)
200
+
201
+ def has_profile(self, profile: str) -> bool:
202
+ return self.get_profile(profile) is not None
203
+
204
+ def _token_key(self, profile: str) -> str:
205
+ return f"{profile}:token"
206
+
207
+ def _login_token_key(self, profile: str) -> str:
208
+ return f"{profile}:login-token"
209
+
210
+ def _upsert_profile(self, profile: SessionProfile) -> None:
211
+ payload = self._load_profiles()
212
+ payload.setdefault("profiles", {})[profile.profile] = asdict(profile)
213
+ self._save_profiles(payload)
214
+
215
+ def _load_profiles(self) -> JSONObject:
216
+ if not self._profiles_path.exists():
217
+ return {"profiles": {}}
218
+ with self._profiles_path.open("r", encoding="utf-8") as handle:
219
+ return json.load(handle)
220
+
221
+ def _save_profiles(self, payload: JSONObject) -> None:
222
+ self._profiles_path.parent.mkdir(parents=True, exist_ok=True)
223
+ with self._profiles_path.open("w", encoding="utf-8") as handle:
224
+ json.dump(payload, handle, ensure_ascii=False, indent=2)
225
+
226
+ def _set_secret(self, key: str, value: str) -> bool:
227
+ if self._keyring is None:
228
+ return False
229
+ try:
230
+ self._keyring.set_password(KEYRING_SERVICE_NAME, key, value)
231
+ return True
232
+ except Exception:
233
+ return False
234
+
235
+ def _get_secret(self, key: str) -> str | None:
236
+ if self._keyring is None:
237
+ return None
238
+ try:
239
+ return self._keyring.get_password(KEYRING_SERVICE_NAME, key)
240
+ except Exception:
241
+ return None
242
+
243
+ def _delete_secret(self, key: str) -> None:
244
+ if self._keyring is None:
245
+ return
246
+ try:
247
+ self._keyring.delete_password(KEYRING_SERVICE_NAME, key)
248
+ except Exception:
249
+ return
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .normalizer import normalize_solution_spec
4
+ from .spec_models import SolutionSpec
5
+
6
+ __all__ = ["SolutionSpec", "normalize_solution_spec"]