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.
Files changed (46) hide show
  1. toolframe_core/__init__.py +3 -0
  2. toolframe_core/_state_postgres.py +420 -0
  3. toolframe_core/_state_sqlite.py +339 -0
  4. toolframe_core/access.py +513 -0
  5. toolframe_core/api.py +700 -0
  6. toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
  7. toolframe_core/bundled_skills/toolframe-plugin-design/SKILL.md +59 -0
  8. toolframe_core/bundled_skills/toolframe-plugin-design/agents/openai.yaml +4 -0
  9. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/LICENSE +21 -0
  10. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/pyproject.toml +21 -0
  11. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/__init__.py +1 -0
  12. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/docs/usage.md +13 -0
  13. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/migrations/0001_init.sql +5 -0
  14. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/page.py +25 -0
  15. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/plugin.py +119 -0
  16. toolframe_core/bundled_skills/toolframe-plugin-design/assets/sample-plugin/src/toolframe_sample_notes/service.py +48 -0
  17. toolframe_core/bundled_skills/toolframe-plugin-design/references/design.md +60 -0
  18. toolframe_core/bundled_skills/toolframe-plugin-design/references/operations.md +133 -0
  19. toolframe_core/bundled_skills/toolframe-plugin-design/references/plugin-api.md +186 -0
  20. toolframe_core/cli.py +681 -0
  21. toolframe_core/config.py +267 -0
  22. toolframe_core/diagnostics.py +200 -0
  23. toolframe_core/graph.py +207 -0
  24. toolframe_core/host.py +2610 -0
  25. toolframe_core/maintenance.py +868 -0
  26. toolframe_core/mcp_access.py +259 -0
  27. toolframe_core/network.py +97 -0
  28. toolframe_core/oidc_access.py +187 -0
  29. toolframe_core/packages.py +728 -0
  30. toolframe_core/process.py +212 -0
  31. toolframe_core/redaction.py +120 -0
  32. toolframe_core/scaffold.py +142 -0
  33. toolframe_core/skills.py +210 -0
  34. toolframe_core/state.py +226 -0
  35. toolframe_core/state_admin.py +270 -0
  36. toolframe_core/stores.py +88 -0
  37. toolframe_core/templates.json +6 -0
  38. toolframe_core/templates.py +56 -0
  39. toolframe_core-0.6.2.dist-info/METADATA +465 -0
  40. toolframe_core-0.6.2.dist-info/RECORD +46 -0
  41. toolframe_core-0.6.2.dist-info/WHEEL +4 -0
  42. toolframe_core-0.6.2.dist-info/entry_points.txt +2 -0
  43. toolframe_core-0.6.2.dist-info/licenses/LICENSE +202 -0
  44. toolframe_core-0.6.2.dist-info/licenses/NOTICE +14 -0
  45. toolframe_core-0.6.2.dist-info/licenses/src/toolframe_core/bundled_skills/toolframe-plugin-design/LICENSE +21 -0
  46. 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())