toolframe-core 0.6.2__py3-none-any.whl
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.
- toolframe_core/__init__.py +3 -0
- toolframe_core/_state_postgres.py +420 -0
- toolframe_core/_state_sqlite.py +339 -0
- toolframe_core/access.py +513 -0
- toolframe_core/api.py +700 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/SKILL.md +59 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/agents/openai.yaml +4 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/pyproject.toml +21 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/__init__.py +1 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/docs/usage.md +13 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/migrations/0001_init.sql +5 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/page.py +25 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/plugin.py +119 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/service.py +48 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/design.md +60 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/operations.md +133 -0
- toolframe_core/bundled_skills/toolframe-plugin-design/references/plugin-api.md +186 -0
- toolframe_core/cli.py +681 -0
- toolframe_core/config.py +267 -0
- toolframe_core/diagnostics.py +200 -0
- toolframe_core/graph.py +207 -0
- toolframe_core/host.py +2610 -0
- toolframe_core/maintenance.py +868 -0
- toolframe_core/mcp_access.py +259 -0
- toolframe_core/network.py +97 -0
- toolframe_core/oidc_access.py +187 -0
- toolframe_core/packages.py +728 -0
- toolframe_core/process.py +212 -0
- toolframe_core/redaction.py +120 -0
- toolframe_core/scaffold.py +142 -0
- toolframe_core/skills.py +210 -0
- toolframe_core/state.py +226 -0
- toolframe_core/state_admin.py +270 -0
- toolframe_core/stores.py +88 -0
- toolframe_core/templates.json +6 -0
- toolframe_core/templates.py +56 -0
- toolframe_core-0.6.2.dist-info/METADATA +465 -0
- toolframe_core-0.6.2.dist-info/RECORD +46 -0
- toolframe_core-0.6.2.dist-info/WHEEL +4 -0
- toolframe_core-0.6.2.dist-info/entry_points.txt +2 -0
- toolframe_core-0.6.2.dist-info/licenses/LICENSE +202 -0
- toolframe_core-0.6.2.dist-info/licenses/NOTICE +14 -0
- toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
- toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
toolframe_core/cli.py
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
"""One operator surface over the same persistent management operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import urllib.parse
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import uvicorn
|
|
15
|
+
from toolframe_sdk import PluginError
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .config import Application, ConfigFile, management_token
|
|
19
|
+
from .network import bind_host, client_host, http_url
|
|
20
|
+
|
|
21
|
+
_SOURCE_PREFIXES = ("git+", "pypi:", "https://", "path:", "wheel:")
|
|
22
|
+
_PENDING_OPERATION_STATES = ("queued", "running", "cancelling")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _add_json_option(command):
|
|
26
|
+
# Every parser owns the option so it works before or after any subcommand.
|
|
27
|
+
# Suppressed defaults keep a value supplied by a parent parser intact.
|
|
28
|
+
command.add_argument(
|
|
29
|
+
"--json", dest="json", action="store_true", default=argparse.SUPPRESS
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _add_no_wait(command):
|
|
34
|
+
command.add_argument("--no-wait", action="store_true")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _add_expected_revision(command):
|
|
38
|
+
command.add_argument("--expected-revision")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parser():
|
|
42
|
+
result = argparse.ArgumentParser(
|
|
43
|
+
prog="toolframe",
|
|
44
|
+
epilog=(
|
|
45
|
+
"Built launcher options: --offline (cached assets only), "
|
|
46
|
+
"--wheelhouse PATH (local wheels), --uv-path PATH (uv executable)."
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
result.add_argument(
|
|
50
|
+
"--version", "-V", action="version", version=f"toolframe {__version__}"
|
|
51
|
+
)
|
|
52
|
+
result.add_argument("--home", type=Path, default=Path.cwd())
|
|
53
|
+
_add_json_option(result)
|
|
54
|
+
commands = result.add_subparsers(dest="command", required=True)
|
|
55
|
+
|
|
56
|
+
initialize = commands.add_parser("init")
|
|
57
|
+
_add_json_option(initialize)
|
|
58
|
+
initialize.add_argument("--name")
|
|
59
|
+
initialize.add_argument("--template")
|
|
60
|
+
initialize.add_argument(
|
|
61
|
+
"--host",
|
|
62
|
+
type=bind_host,
|
|
63
|
+
help="Save the listen address (IPv4/IPv6 or localhost); default 127.0.0.1 for an empty app",
|
|
64
|
+
)
|
|
65
|
+
initialize.add_argument("--port", type=int, default=9876)
|
|
66
|
+
|
|
67
|
+
skill = commands.add_parser("skill", help="Install bundled agent guidance locally")
|
|
68
|
+
_add_json_option(skill)
|
|
69
|
+
skill_commands = skill.add_subparsers(dest="action", required=True)
|
|
70
|
+
skill_install = skill_commands.add_parser("install")
|
|
71
|
+
_add_json_option(skill_install)
|
|
72
|
+
skill_install.add_argument(
|
|
73
|
+
"--output",
|
|
74
|
+
type=Path,
|
|
75
|
+
help="Exact destination directory; defaults to APP/.agents/skills/toolframe-plugin-design under --home",
|
|
76
|
+
)
|
|
77
|
+
skill_install.add_argument(
|
|
78
|
+
"--overwrite",
|
|
79
|
+
action="store_true",
|
|
80
|
+
help="Confirm replacement of customized content; saves a backup ZIP",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
serve = commands.add_parser("serve")
|
|
84
|
+
_add_json_option(serve)
|
|
85
|
+
serve.add_argument(
|
|
86
|
+
"--host",
|
|
87
|
+
type=bind_host,
|
|
88
|
+
help="Override the listen address for this run, e.g. 0.0.0.0 for LAN access",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
status = commands.add_parser("status")
|
|
92
|
+
_add_json_option(status)
|
|
93
|
+
status.add_argument("--check", action="store_true")
|
|
94
|
+
|
|
95
|
+
open_command = commands.add_parser("open")
|
|
96
|
+
_add_json_option(open_command)
|
|
97
|
+
|
|
98
|
+
plan = commands.add_parser("plan")
|
|
99
|
+
_add_json_option(plan)
|
|
100
|
+
plan.add_argument(
|
|
101
|
+
"--action",
|
|
102
|
+
choices=("apply", "reload", "enable", "disable", "remove", "stop"),
|
|
103
|
+
default="apply",
|
|
104
|
+
)
|
|
105
|
+
plan.add_argument("--id")
|
|
106
|
+
_add_no_wait(plan)
|
|
107
|
+
|
|
108
|
+
export = commands.add_parser("export")
|
|
109
|
+
_add_json_option(export)
|
|
110
|
+
export.add_argument("--output", type=Path)
|
|
111
|
+
|
|
112
|
+
apply = commands.add_parser("apply")
|
|
113
|
+
_add_json_option(apply)
|
|
114
|
+
apply.add_argument("--plan-id")
|
|
115
|
+
_add_expected_revision(apply)
|
|
116
|
+
_add_no_wait(apply)
|
|
117
|
+
|
|
118
|
+
plugin = commands.add_parser("plugin")
|
|
119
|
+
_add_json_option(plugin)
|
|
120
|
+
plugin_commands = plugin.add_subparsers(dest="action", required=True)
|
|
121
|
+
|
|
122
|
+
new = plugin_commands.add_parser(
|
|
123
|
+
"new", help="Generate a new plugin's source locally"
|
|
124
|
+
)
|
|
125
|
+
_add_json_option(new)
|
|
126
|
+
new.add_argument("id")
|
|
127
|
+
new.add_argument(
|
|
128
|
+
"--output",
|
|
129
|
+
type=Path,
|
|
130
|
+
help="Exact destination directory; defaults to APP/plugins/PLUGIN-ID under --home",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
add = plugin_commands.add_parser("add")
|
|
134
|
+
_add_json_option(add)
|
|
135
|
+
add.add_argument("source")
|
|
136
|
+
add.add_argument("--id")
|
|
137
|
+
add.add_argument("--enable", action="store_true")
|
|
138
|
+
_add_expected_revision(add)
|
|
139
|
+
_add_no_wait(add)
|
|
140
|
+
|
|
141
|
+
plugin_action_names = (
|
|
142
|
+
"validate",
|
|
143
|
+
"update",
|
|
144
|
+
"reload",
|
|
145
|
+
"enable",
|
|
146
|
+
"disable",
|
|
147
|
+
"remove",
|
|
148
|
+
"stop",
|
|
149
|
+
"status",
|
|
150
|
+
"logs",
|
|
151
|
+
)
|
|
152
|
+
for name in plugin_action_names:
|
|
153
|
+
command = plugin_commands.add_parser(name)
|
|
154
|
+
_add_json_option(command)
|
|
155
|
+
command.add_argument("id", nargs="?" if name == "status" else None)
|
|
156
|
+
if name == "validate":
|
|
157
|
+
command.add_argument("--runtime", action="store_true")
|
|
158
|
+
if name == "update":
|
|
159
|
+
command.add_argument("--source")
|
|
160
|
+
if name in ("reload", "enable", "disable", "remove", "stop"):
|
|
161
|
+
command.add_argument("--plan-id")
|
|
162
|
+
if name == "stop":
|
|
163
|
+
command.add_argument("--force", action="store_true")
|
|
164
|
+
if name == "status":
|
|
165
|
+
command.add_argument("--check", action="store_true")
|
|
166
|
+
if name not in ("status", "logs"):
|
|
167
|
+
_add_expected_revision(command)
|
|
168
|
+
_add_no_wait(command)
|
|
169
|
+
|
|
170
|
+
migration = commands.add_parser("migrate")
|
|
171
|
+
_add_json_option(migration)
|
|
172
|
+
migration_commands = migration.add_subparsers(dest="action", required=True)
|
|
173
|
+
migration_plan = migration_commands.add_parser("plan")
|
|
174
|
+
_add_json_option(migration_plan)
|
|
175
|
+
migration_plan.add_argument("id")
|
|
176
|
+
_add_no_wait(migration_plan)
|
|
177
|
+
migration_apply = migration_commands.add_parser("apply")
|
|
178
|
+
_add_json_option(migration_apply)
|
|
179
|
+
migration_apply.add_argument("id")
|
|
180
|
+
migration_apply.add_argument("--plan-id", required=True)
|
|
181
|
+
_add_expected_revision(migration_apply)
|
|
182
|
+
_add_no_wait(migration_apply)
|
|
183
|
+
|
|
184
|
+
operation = commands.add_parser("operation")
|
|
185
|
+
_add_json_option(operation)
|
|
186
|
+
operation_commands = operation.add_subparsers(dest="action", required=True)
|
|
187
|
+
for name in ("status", "cancel"):
|
|
188
|
+
command = operation_commands.add_parser(name)
|
|
189
|
+
_add_json_option(command)
|
|
190
|
+
command.add_argument("id")
|
|
191
|
+
|
|
192
|
+
state = commands.add_parser(
|
|
193
|
+
"state", help="Provision or recover a Core PostgreSQL journal"
|
|
194
|
+
)
|
|
195
|
+
_add_json_option(state)
|
|
196
|
+
states = state.add_subparsers(dest="action", required=True)
|
|
197
|
+
setup = states.add_parser("setup")
|
|
198
|
+
for name in ("database", "application-id", "node-id", "output"):
|
|
199
|
+
setup.add_argument("--" + name, required=True)
|
|
200
|
+
inspect = states.add_parser("inspect")
|
|
201
|
+
recover = states.add_parser("recover")
|
|
202
|
+
recover.add_argument("--expected-run", required=True)
|
|
203
|
+
recover.add_argument("--confirm-workers-stopped", action="store_true")
|
|
204
|
+
launch = states.add_parser(
|
|
205
|
+
"run", help="Load a private runtime environment document and exec a command"
|
|
206
|
+
)
|
|
207
|
+
launch.add_argument("--env-file", required=True, type=Path)
|
|
208
|
+
launch.add_argument("argv", nargs=argparse.REMAINDER)
|
|
209
|
+
for command in (setup, inspect, recover, launch):
|
|
210
|
+
_add_json_option(command)
|
|
211
|
+
return result
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _normalize_source(source):
|
|
215
|
+
if source and not source.startswith(_SOURCE_PREFIXES):
|
|
216
|
+
return str(Path(source).resolve())
|
|
217
|
+
return source
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _read_endpoint_file(config):
|
|
221
|
+
fallback = ("127.0.0.1", 9876)
|
|
222
|
+
try:
|
|
223
|
+
value = json.loads((config.home / ".toolframe" / "endpoint.json").read_text())
|
|
224
|
+
host = value["host"]
|
|
225
|
+
port = int(value["port"])
|
|
226
|
+
endpoint = Application(id="endpoint", host=host, port=port)
|
|
227
|
+
return endpoint.host, endpoint.port
|
|
228
|
+
except OSError, TypeError, ValueError, KeyError, json.JSONDecodeError:
|
|
229
|
+
return fallback
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _endpoint(config):
|
|
233
|
+
if (config.home / ".toolframe/endpoint.json").exists():
|
|
234
|
+
return _read_endpoint_file(config)
|
|
235
|
+
try:
|
|
236
|
+
composition, _ = config.read()
|
|
237
|
+
except PluginError:
|
|
238
|
+
return _read_endpoint_file(config)
|
|
239
|
+
application = composition.application
|
|
240
|
+
return application.host, application.port
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _base_url(config):
|
|
244
|
+
host, port = _endpoint(config)
|
|
245
|
+
return http_url(client_host(host), port)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _emit_json(value):
|
|
249
|
+
print(json.dumps(value, ensure_ascii=False, indent=2))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _error_code(error):
|
|
253
|
+
if isinstance(error, PluginError):
|
|
254
|
+
return error.code
|
|
255
|
+
if isinstance(error, httpx.HTTPStatusError):
|
|
256
|
+
response = error.response
|
|
257
|
+
if response.status_code == 409:
|
|
258
|
+
return "idempotency_conflict"
|
|
259
|
+
if response.status_code == 422:
|
|
260
|
+
return "malformed_input"
|
|
261
|
+
if response.status_code == 503:
|
|
262
|
+
return "runtime"
|
|
263
|
+
payload = _http_error_payload(response)
|
|
264
|
+
return payload.get("code", "http_error")
|
|
265
|
+
if isinstance(error, ValueError):
|
|
266
|
+
return "malformed_input"
|
|
267
|
+
return "runtime"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _exit_code(code):
|
|
271
|
+
if code in {
|
|
272
|
+
"invalid_config",
|
|
273
|
+
"configuration_invalid",
|
|
274
|
+
"configuration_exists",
|
|
275
|
+
"config_invalid",
|
|
276
|
+
"config_missing",
|
|
277
|
+
"malformed_input",
|
|
278
|
+
"invalid_input",
|
|
279
|
+
"invalid_request",
|
|
280
|
+
}:
|
|
281
|
+
return 2
|
|
282
|
+
if code in {
|
|
283
|
+
"validation",
|
|
284
|
+
"eligibility",
|
|
285
|
+
"migration_required",
|
|
286
|
+
"dependency",
|
|
287
|
+
"dependency_unavailable",
|
|
288
|
+
"storage_unavailable",
|
|
289
|
+
"schema",
|
|
290
|
+
"store_not_declared",
|
|
291
|
+
"plugin_missing",
|
|
292
|
+
"source_invalid",
|
|
293
|
+
"identity_mismatch",
|
|
294
|
+
"package_command_failed",
|
|
295
|
+
"provider_conflict",
|
|
296
|
+
"service_ambiguous",
|
|
297
|
+
"resource_target_invalid",
|
|
298
|
+
} or code.startswith(("validation_", "eligibility_", "dependency_", "schema_")):
|
|
299
|
+
return 3
|
|
300
|
+
if code in {
|
|
301
|
+
"runtime",
|
|
302
|
+
"cleanup",
|
|
303
|
+
"forward_repair",
|
|
304
|
+
"failed_recovered",
|
|
305
|
+
"interrupted",
|
|
306
|
+
"operation_failed",
|
|
307
|
+
} or code.startswith(("runtime_", "cleanup_", "forward_repair_")):
|
|
308
|
+
return 4
|
|
309
|
+
if code in {
|
|
310
|
+
"stale_plan",
|
|
311
|
+
"stale_revision",
|
|
312
|
+
"idempotency_conflict",
|
|
313
|
+
"already_installed",
|
|
314
|
+
"skill_conflict",
|
|
315
|
+
"scaffold_conflict",
|
|
316
|
+
}:
|
|
317
|
+
return 5
|
|
318
|
+
if code == "cancelled":
|
|
319
|
+
return 130
|
|
320
|
+
return 4
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _http_error_payload(response):
|
|
324
|
+
try:
|
|
325
|
+
payload = response.json()
|
|
326
|
+
except TypeError, ValueError:
|
|
327
|
+
payload = None
|
|
328
|
+
if isinstance(payload, dict) and isinstance(payload.get("error"), dict):
|
|
329
|
+
return payload["error"]
|
|
330
|
+
message = getattr(response, "text", "") or f"HTTP {response.status_code}"
|
|
331
|
+
return {"code": "http_error", "message": message, "details": {}}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _failure_payload(error):
|
|
335
|
+
if isinstance(error, PluginError):
|
|
336
|
+
return error.as_dict()
|
|
337
|
+
if isinstance(error, httpx.HTTPStatusError):
|
|
338
|
+
return _http_error_payload(error.response)
|
|
339
|
+
if isinstance(error, httpx.HTTPError):
|
|
340
|
+
return {"code": "connection_error", "message": str(error), "details": {}}
|
|
341
|
+
if isinstance(error, ValueError):
|
|
342
|
+
return {"code": "malformed_input", "message": str(error), "details": {}}
|
|
343
|
+
if isinstance(error, OSError):
|
|
344
|
+
return {"code": "io_error", "message": str(error), "details": {}}
|
|
345
|
+
return {"code": "runtime", "message": str(error), "details": {}}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _response_json(response):
|
|
349
|
+
response.raise_for_status()
|
|
350
|
+
try:
|
|
351
|
+
return response.json()
|
|
352
|
+
except ValueError as error:
|
|
353
|
+
raise ValueError("Management API returned malformed JSON") from error
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _wait_for_operation(client, result):
|
|
357
|
+
while (
|
|
358
|
+
isinstance(result, dict) and result.get("status") in _PENDING_OPERATION_STATES
|
|
359
|
+
):
|
|
360
|
+
time.sleep(0.2)
|
|
361
|
+
result = _response_json(client.get(f"/_toolframe/v1/operations/{result['id']}"))
|
|
362
|
+
return result
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _operation_exit_code(result, *, no_wait=False):
|
|
366
|
+
if not isinstance(result, dict):
|
|
367
|
+
return 4
|
|
368
|
+
status = result.get("status")
|
|
369
|
+
if status == "succeeded":
|
|
370
|
+
return 0
|
|
371
|
+
if no_wait and status in _PENDING_OPERATION_STATES:
|
|
372
|
+
return 0
|
|
373
|
+
if status == "cancelled":
|
|
374
|
+
return 130
|
|
375
|
+
if status in ("failed_recovered", "interrupted"):
|
|
376
|
+
return 4
|
|
377
|
+
if status == "failed":
|
|
378
|
+
error = result.get("error") or {}
|
|
379
|
+
return _exit_code(error.get("code", "runtime"))
|
|
380
|
+
return 4
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _status_check(result, *, plugin=False):
|
|
384
|
+
if plugin:
|
|
385
|
+
rows = result if isinstance(result, list) else [result]
|
|
386
|
+
return (
|
|
387
|
+
0
|
|
388
|
+
if rows
|
|
389
|
+
and all(
|
|
390
|
+
bool(row.get("active"))
|
|
391
|
+
and isinstance(row.get("runtime"), dict)
|
|
392
|
+
and row["runtime"].get("state") == "active"
|
|
393
|
+
and row.get("health_fresh") is True
|
|
394
|
+
for row in rows
|
|
395
|
+
)
|
|
396
|
+
else 3
|
|
397
|
+
)
|
|
398
|
+
if not isinstance(result, dict):
|
|
399
|
+
return 3
|
|
400
|
+
if result.get("configuration_error"):
|
|
401
|
+
return 3
|
|
402
|
+
if "ready" in result:
|
|
403
|
+
return 0 if result["ready"] is True else 3
|
|
404
|
+
if result.get("application") in ("not_ready", "degraded"):
|
|
405
|
+
return 3
|
|
406
|
+
if result.get("application") == "ready":
|
|
407
|
+
return 0
|
|
408
|
+
return 3
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _serve(config, *, listen_host=None):
|
|
412
|
+
return asyncio.run(_serve_async(config, listen_host=listen_host))
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
async def _serve_async(config, *, listen_host=None):
|
|
416
|
+
# Host owns the invalid-config fallback and is the only host constructed by
|
|
417
|
+
# the CLI. Its application is therefore the source of the listen address.
|
|
418
|
+
from .api import create_app
|
|
419
|
+
from .host import Host
|
|
420
|
+
|
|
421
|
+
control = await Host.create(config.home, listen_host=listen_host)
|
|
422
|
+
application = control.composition.application
|
|
423
|
+
host = control.listen_host
|
|
424
|
+
authority = f"[{host}]" if ":" in host else host
|
|
425
|
+
print(
|
|
426
|
+
f"Toolframe: {http_url(client_host(host), application.port)} "
|
|
427
|
+
f"· listening on {authority}:{application.port} · management token: "
|
|
428
|
+
f"{config.home / '.toolframe/management.token'}",
|
|
429
|
+
flush=True,
|
|
430
|
+
)
|
|
431
|
+
server = uvicorn.Server(
|
|
432
|
+
uvicorn.Config(
|
|
433
|
+
create_app(control),
|
|
434
|
+
host=host,
|
|
435
|
+
port=application.port,
|
|
436
|
+
log_level="info",
|
|
437
|
+
access_log=False,
|
|
438
|
+
proxy_headers=False,
|
|
439
|
+
)
|
|
440
|
+
)
|
|
441
|
+
try:
|
|
442
|
+
await server.serve()
|
|
443
|
+
finally:
|
|
444
|
+
await control.close()
|
|
445
|
+
return 0
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _operation_params(arguments, *, exclude=()):
|
|
449
|
+
excluded = {
|
|
450
|
+
"home",
|
|
451
|
+
"command",
|
|
452
|
+
"action",
|
|
453
|
+
"no_wait",
|
|
454
|
+
"json",
|
|
455
|
+
"check",
|
|
456
|
+
*exclude,
|
|
457
|
+
}
|
|
458
|
+
params = {
|
|
459
|
+
key: value
|
|
460
|
+
for key, value in vars(arguments).items()
|
|
461
|
+
if key not in excluded and value is not None
|
|
462
|
+
}
|
|
463
|
+
if "source" in params:
|
|
464
|
+
params["source"] = _normalize_source(params["source"])
|
|
465
|
+
return params
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _install_skill(arguments, home, *, upgrade_only=False):
|
|
469
|
+
from .skills import install_skill
|
|
470
|
+
|
|
471
|
+
def confirm(result):
|
|
472
|
+
print(
|
|
473
|
+
f"Skill at {result['path']} has local changes or no installation record. "
|
|
474
|
+
f"Replace it with bundled version {result['bundled_version']} and save a backup? [y/N] ",
|
|
475
|
+
end="",
|
|
476
|
+
file=sys.stderr,
|
|
477
|
+
flush=True,
|
|
478
|
+
)
|
|
479
|
+
return sys.stdin.readline().strip().lower() in {"y", "yes"}
|
|
480
|
+
|
|
481
|
+
return install_skill(
|
|
482
|
+
home,
|
|
483
|
+
output=getattr(arguments, "output", None),
|
|
484
|
+
overwrite=getattr(arguments, "overwrite", False),
|
|
485
|
+
confirm=confirm
|
|
486
|
+
if sys.stdin.isatty() and not getattr(arguments, "json", False)
|
|
487
|
+
else None,
|
|
488
|
+
upgrade_only=upgrade_only,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def run(arguments):
|
|
493
|
+
if arguments.command == "state":
|
|
494
|
+
from .state_admin import run as state_run
|
|
495
|
+
|
|
496
|
+
_emit_json(state_run(arguments))
|
|
497
|
+
return 0
|
|
498
|
+
config = ConfigFile(arguments.home)
|
|
499
|
+
if arguments.command == "plugin" and arguments.action == "new":
|
|
500
|
+
from .scaffold import new_plugin
|
|
501
|
+
|
|
502
|
+
result = new_plugin(config.home, arguments.id, output=arguments.output)
|
|
503
|
+
if getattr(arguments, "json", False):
|
|
504
|
+
_emit_json(result)
|
|
505
|
+
else:
|
|
506
|
+
print(f"Created plugin {result['plugin_id']} at {result['path']}")
|
|
507
|
+
return 0
|
|
508
|
+
if arguments.command == "skill":
|
|
509
|
+
from .skills import installation_message
|
|
510
|
+
|
|
511
|
+
result = _install_skill(arguments, config.home)
|
|
512
|
+
if getattr(arguments, "json", False):
|
|
513
|
+
_emit_json(result)
|
|
514
|
+
else:
|
|
515
|
+
print(installation_message(result))
|
|
516
|
+
return (
|
|
517
|
+
5
|
|
518
|
+
if result["confirmation_required"]
|
|
519
|
+
or result.get("reason") == "non_regular_destination"
|
|
520
|
+
else 0
|
|
521
|
+
)
|
|
522
|
+
if arguments.command == "init":
|
|
523
|
+
if arguments.template:
|
|
524
|
+
from .templates import initialize_template
|
|
525
|
+
|
|
526
|
+
asyncio.run(
|
|
527
|
+
initialize_template(
|
|
528
|
+
config,
|
|
529
|
+
arguments.template,
|
|
530
|
+
name=arguments.name,
|
|
531
|
+
host=arguments.host,
|
|
532
|
+
port=arguments.port,
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
else:
|
|
536
|
+
config.initialize(
|
|
537
|
+
name=arguments.name or "Toolframe",
|
|
538
|
+
host=arguments.host or "127.0.0.1",
|
|
539
|
+
port=arguments.port,
|
|
540
|
+
)
|
|
541
|
+
from .skills import installation_message
|
|
542
|
+
|
|
543
|
+
skill = _install_skill(arguments, config.home)
|
|
544
|
+
if getattr(arguments, "json", False):
|
|
545
|
+
_emit_json({"configuration": str(config.path), "skill": skill})
|
|
546
|
+
else:
|
|
547
|
+
print(
|
|
548
|
+
f"Initialized {config.path}. Run toolframe --home {config.home} serve"
|
|
549
|
+
)
|
|
550
|
+
print(installation_message(skill))
|
|
551
|
+
return 0
|
|
552
|
+
if arguments.command == "serve":
|
|
553
|
+
from .skills import installation_message
|
|
554
|
+
|
|
555
|
+
try:
|
|
556
|
+
skill = _install_skill(arguments, config.home, upgrade_only=True)
|
|
557
|
+
if skill["status"] != "unchanged":
|
|
558
|
+
print(installation_message(skill), file=sys.stderr)
|
|
559
|
+
except (OSError, ValueError, PluginError) as error:
|
|
560
|
+
# Authoring guidance must not prevent management and recovery startup.
|
|
561
|
+
print(
|
|
562
|
+
f"Skill refresh skipped: {error}. Use skill install to retry.",
|
|
563
|
+
file=sys.stderr,
|
|
564
|
+
)
|
|
565
|
+
return _serve(config, listen_host=arguments.host)
|
|
566
|
+
|
|
567
|
+
base = _base_url(config)
|
|
568
|
+
token = management_token(config.home)
|
|
569
|
+
if arguments.command == "open":
|
|
570
|
+
print(f"{base}/_toolframe/session?token={urllib.parse.quote(token)}")
|
|
571
|
+
return 0
|
|
572
|
+
|
|
573
|
+
with httpx.Client(
|
|
574
|
+
base_url=base,
|
|
575
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
576
|
+
timeout=30,
|
|
577
|
+
trust_env=False,
|
|
578
|
+
) as client:
|
|
579
|
+
if arguments.command == "export":
|
|
580
|
+
payload = _response_json(client.get("/_toolframe/v1/composition"))
|
|
581
|
+
if not isinstance(payload, dict) or not isinstance(
|
|
582
|
+
payload.get("toml"), str
|
|
583
|
+
):
|
|
584
|
+
raise ValueError("Composition response did not contain TOML")
|
|
585
|
+
text = payload["toml"]
|
|
586
|
+
if arguments.output:
|
|
587
|
+
try:
|
|
588
|
+
with arguments.output.open("x", encoding="utf-8") as handle:
|
|
589
|
+
handle.write(text)
|
|
590
|
+
except OSError as error:
|
|
591
|
+
raise ValueError(
|
|
592
|
+
f"Refusing to overwrite or create output {arguments.output}"
|
|
593
|
+
) from error
|
|
594
|
+
else:
|
|
595
|
+
sys.stdout.write(text)
|
|
596
|
+
return 0
|
|
597
|
+
|
|
598
|
+
if arguments.command == "status" or (
|
|
599
|
+
arguments.command == "plugin" and arguments.action == "status"
|
|
600
|
+
):
|
|
601
|
+
result = _response_json(client.get("/_toolframe/v1/status"))
|
|
602
|
+
plugin_status = arguments.command == "plugin"
|
|
603
|
+
if plugin_status and getattr(arguments, "id", None):
|
|
604
|
+
result = next(
|
|
605
|
+
(
|
|
606
|
+
item
|
|
607
|
+
for item in result.get("plugins", [])
|
|
608
|
+
if item["id"] == arguments.id
|
|
609
|
+
),
|
|
610
|
+
{},
|
|
611
|
+
)
|
|
612
|
+
_emit_json(result)
|
|
613
|
+
if getattr(arguments, "check", False):
|
|
614
|
+
if plugin_status and not getattr(arguments, "id", None):
|
|
615
|
+
result = result.get("plugins", [])
|
|
616
|
+
return _status_check(result, plugin=plugin_status)
|
|
617
|
+
return 0
|
|
618
|
+
|
|
619
|
+
if arguments.command == "plugin" and arguments.action == "logs":
|
|
620
|
+
result = _response_json(
|
|
621
|
+
client.get(f"/_toolframe/v1/plugins/{arguments.id}/logs")
|
|
622
|
+
)
|
|
623
|
+
_emit_json(result)
|
|
624
|
+
return 0
|
|
625
|
+
|
|
626
|
+
if arguments.command == "operation":
|
|
627
|
+
path = f"/_toolframe/v1/operations/{arguments.id}"
|
|
628
|
+
if arguments.action == "cancel":
|
|
629
|
+
result = _response_json(client.post(path + "/cancel"))
|
|
630
|
+
_emit_json(result)
|
|
631
|
+
return 0
|
|
632
|
+
result = _response_json(client.get(path))
|
|
633
|
+
_emit_json(result)
|
|
634
|
+
if (
|
|
635
|
+
isinstance(result, dict)
|
|
636
|
+
and result.get("status") in _PENDING_OPERATION_STATES
|
|
637
|
+
):
|
|
638
|
+
return 0
|
|
639
|
+
return _operation_exit_code(result)
|
|
640
|
+
|
|
641
|
+
if arguments.command == "plan":
|
|
642
|
+
params = {"action": arguments.action}
|
|
643
|
+
if arguments.id is not None:
|
|
644
|
+
params["id"] = arguments.id
|
|
645
|
+
action = "plan"
|
|
646
|
+
elif arguments.command == "apply":
|
|
647
|
+
params = _operation_params(arguments)
|
|
648
|
+
action = "apply"
|
|
649
|
+
elif arguments.command == "plugin":
|
|
650
|
+
params = _operation_params(arguments)
|
|
651
|
+
action = arguments.action
|
|
652
|
+
elif arguments.command == "migrate":
|
|
653
|
+
params = _operation_params(arguments)
|
|
654
|
+
action = f"migrate-{arguments.action}"
|
|
655
|
+
else:
|
|
656
|
+
raise ValueError(f"Unsupported command {arguments.command}")
|
|
657
|
+
|
|
658
|
+
result = _response_json(
|
|
659
|
+
client.post(
|
|
660
|
+
"/_toolframe/v1/operations", json={"action": action, "params": params}
|
|
661
|
+
)
|
|
662
|
+
)
|
|
663
|
+
no_wait = getattr(arguments, "no_wait", False)
|
|
664
|
+
if not no_wait:
|
|
665
|
+
result = _wait_for_operation(client, result)
|
|
666
|
+
_emit_json(result)
|
|
667
|
+
return _operation_exit_code(result, no_wait=no_wait)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def main(argv=None):
|
|
671
|
+
try:
|
|
672
|
+
arguments = parser().parse_args(argv)
|
|
673
|
+
return run(arguments)
|
|
674
|
+
except (PluginError, httpx.HTTPError, OSError, ValueError) as error:
|
|
675
|
+
value = _failure_payload(error)
|
|
676
|
+
print(json.dumps({"error": value}, ensure_ascii=False), file=sys.stderr)
|
|
677
|
+
return _exit_code(_error_code(error))
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
if __name__ == "__main__":
|
|
681
|
+
raise SystemExit(main())
|