@reunionstudio/airlock-mcp 0.1.0

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 (57) hide show
  1. package/.agents/skills/airlock-mcp/SKILL.md +122 -0
  2. package/.agents/skills/airlock-mcp/agents/openai.yaml +4 -0
  3. package/LICENSE +187 -0
  4. package/README.md +126 -0
  5. package/SECURITY.md +31 -0
  6. package/bin/airlock-mcp.mjs +5 -0
  7. package/docs/architecture.md +82 -0
  8. package/docs/install-surface.md +112 -0
  9. package/docs/ooda-loop.md +40 -0
  10. package/docs/spec-workbench-architecture.md +161 -0
  11. package/docs/spec-workspace.md +33 -0
  12. package/docs/workflows.md +229 -0
  13. package/package.json +46 -0
  14. package/patterns/blank/README.md +14 -0
  15. package/patterns/blank/sample.records.json +19 -0
  16. package/patterns/blank/spec.config.json +72 -0
  17. package/patterns/guest-access/individual-isolation.md +27 -0
  18. package/patterns/guest-access/role-isolation.md +26 -0
  19. package/patterns/guest-access/shared-contribution.md +25 -0
  20. package/patterns/manifest.json +16 -0
  21. package/patterns/spec-types/commitment.md +24 -0
  22. package/patterns/spec-types/observation.md +22 -0
  23. package/patterns/spec-types/reconciliation.md +19 -0
  24. package/patterns/spec-types/reference-master-data.md +21 -0
  25. package/patterns/starter-posts/README.md +32 -0
  26. package/patterns/starter-posts/sample.records.json +27 -0
  27. package/patterns/starter-posts/spec.config.json +135 -0
  28. package/schemas/airlock-mcp-workspace.schema.json +14 -0
  29. package/setup.py +41 -0
  30. package/src/airlock_mcp/__init__.py +3 -0
  31. package/src/airlock_mcp/__main__.py +5 -0
  32. package/src/airlock_mcp/art.py +26 -0
  33. package/src/airlock_mcp/bootstrap.py +161 -0
  34. package/src/airlock_mcp/cli.py +450 -0
  35. package/src/airlock_mcp/jsonio.py +56 -0
  36. package/src/airlock_mcp/manage.py +247 -0
  37. package/src/airlock_mcp/models.py +43 -0
  38. package/src/airlock_mcp/patterns.py +49 -0
  39. package/src/airlock_mcp/project.py +39 -0
  40. package/src/airlock_mcp/records.py +43 -0
  41. package/src/airlock_mcp/specs.py +110 -0
  42. package/src/airlock_mcp/sql.py +15 -0
  43. package/src/airlock_mcp/summary.py +115 -0
  44. package/src/airlock_mcp/updater.py +76 -0
  45. package/src/airlock_mcp/validation.py +334 -0
  46. package/src/airlock_mcp/workspace.py +223 -0
  47. package/src/cli.mjs +89 -0
  48. package/src/install.mjs +100 -0
  49. package/src/mcp.mjs +184 -0
  50. package/src/text.mjs +108 -0
  51. package/src/workbench.mjs +368 -0
  52. package/workspaces/_template/brief.md +18 -0
  53. package/workspaces/_template/decisions.md +40 -0
  54. package/workspaces/_template/questions.md +9 -0
  55. package/workspaces/_template/review.md +21 -0
  56. package/workspaces/_template/sample.records.json +19 -0
  57. package/workspaces/_template/spec.config.json +72 -0
@@ -0,0 +1,450 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from . import __version__
8
+ from .art import about_text
9
+ from .bootstrap import bootstrap_repo, format_bootstrap_result
10
+ from .jsonio import read_json
11
+ from .manage import (
12
+ archive_workspace,
13
+ discover_workspaces,
14
+ format_workspace_paths,
15
+ format_workspace_table,
16
+ next_steps,
17
+ rename_workspace,
18
+ restore_workspace,
19
+ )
20
+ from .patterns import load_patterns
21
+ from .project import repo_root
22
+ from .records import records_to_csv
23
+ from .specs import extract_spec_config
24
+ from .sql import render_admin_sql
25
+ from .summary import workspace_summary
26
+ from .updater import build_update_plan, format_update_plan, run_update
27
+ from .validation import check_workspace
28
+ from .workspace import (
29
+ clone_workspace,
30
+ create_workspace_from_pattern,
31
+ create_workspace_from_spec_config,
32
+ )
33
+
34
+
35
+ def _workspace_parent(value: str | None) -> Path:
36
+ return Path(value or "workspaces")
37
+
38
+
39
+ def _workspace_root(value: str | None) -> Path:
40
+ return Path(value or "workspaces")
41
+
42
+
43
+ def _print_check_result(result) -> int:
44
+ for finding in result.findings:
45
+ print(f"{finding.level}: {finding.path}: {finding.message}")
46
+ if result.errors:
47
+ print(f"failed: {len(result.errors)} error(s), {len(result.warnings)} warning(s)")
48
+ return 1
49
+ if result.warnings:
50
+ print(f"ok with warnings: {len(result.warnings)} warning(s)")
51
+ else:
52
+ print("ok")
53
+ return 0
54
+
55
+
56
+ def about(_: argparse.Namespace) -> int:
57
+ print(about_text())
58
+ return 0
59
+
60
+
61
+ def init_repo(args: argparse.Namespace) -> int:
62
+ result = bootstrap_repo(Path(args.path), force=args.force)
63
+ print(format_bootstrap_result(result))
64
+ return 0
65
+
66
+
67
+ def init_workspace(args: argparse.Namespace) -> int:
68
+ patterns = load_patterns(repo_root())
69
+ pattern = patterns[args.pattern]
70
+ target = _workspace_parent(args.output) / args.name
71
+ create_workspace_from_pattern(target, pattern, workspace_name=args.name, force=args.force)
72
+ print(f"created {target}")
73
+ print(f"pattern: {pattern.name} - {pattern.summary}")
74
+ print(f"next: airlock-mcp check {target}")
75
+ return 0
76
+
77
+
78
+ def import_spec(args: argparse.Namespace) -> int:
79
+ source = Path(args.source)
80
+ raw = read_json(source)
81
+ if raw is None:
82
+ print(f"error: could not read JSON source: {source}", file=sys.stderr)
83
+ return 2
84
+ try:
85
+ spec_config = extract_spec_config(raw)
86
+ except ValueError as exc:
87
+ print(f"error: {exc}", file=sys.stderr)
88
+ return 2
89
+
90
+ target = _workspace_parent(args.output) / args.name
91
+ create_workspace_from_spec_config(
92
+ target,
93
+ spec_config,
94
+ mode="import",
95
+ source=str(source),
96
+ force=args.force,
97
+ )
98
+ print(f"imported {source} -> {target}")
99
+ print(f"next: airlock-mcp check {target}")
100
+ return 0
101
+
102
+
103
+ def clone(args: argparse.Namespace) -> int:
104
+ source = Path(args.source_workspace)
105
+ target = _workspace_parent(args.output) / args.name
106
+ clone_workspace(source, target, workspace_name=args.name, force=args.force)
107
+ print(f"cloned {source} -> {target}")
108
+ print(f"next: airlock-mcp check {target}")
109
+ return 0
110
+
111
+
112
+ def check(args: argparse.Namespace) -> int:
113
+ return _print_check_result(check_workspace(Path(args.workspace)))
114
+
115
+
116
+ def list_workspaces(args: argparse.Namespace) -> int:
117
+ infos = discover_workspaces(_workspace_root(args.root), include_archived=args.all)
118
+ if args.paths:
119
+ output = format_workspace_paths(infos)
120
+ if output:
121
+ print(output)
122
+ else:
123
+ print(format_workspace_table(infos))
124
+ return 0
125
+
126
+
127
+ def archive(args: argparse.Namespace) -> int:
128
+ target = archive_workspace(
129
+ Path(args.workspace),
130
+ archive_root=Path(args.archive_root) if args.archive_root else None,
131
+ )
132
+ print(f"archived {args.workspace} -> {target}")
133
+ return 0
134
+
135
+
136
+ def restore(args: argparse.Namespace) -> int:
137
+ target = restore_workspace(
138
+ Path(args.archived_workspace),
139
+ output_root=Path(args.output) if args.output else None,
140
+ )
141
+ print(f"restored {args.archived_workspace} -> {target}")
142
+ return 0
143
+
144
+
145
+ def rename(args: argparse.Namespace) -> int:
146
+ target = rename_workspace(
147
+ Path(args.workspace),
148
+ args.name,
149
+ output_root=Path(args.output) if args.output else None,
150
+ retitle=not args.keep_spec_identity,
151
+ )
152
+ print(f"renamed {args.workspace} -> {target}")
153
+ if args.keep_spec_identity:
154
+ print("spec identity preserved")
155
+ else:
156
+ print("spec identity retitled")
157
+ return 0
158
+
159
+
160
+ def summary(args: argparse.Namespace) -> int:
161
+ workspace = Path(args.workspace)
162
+ result = check_workspace(workspace)
163
+ spec = read_json(workspace / "spec.config.json")
164
+ sample = read_json(workspace / "sample.records.json")
165
+ if not isinstance(spec, dict) or not isinstance(sample, dict):
166
+ return _print_check_result(result)
167
+ print(workspace_summary(workspace, spec, sample, result))
168
+ return 1 if result.errors else 0
169
+
170
+
171
+ def next_action(args: argparse.Namespace) -> int:
172
+ print(next_steps(Path(args.workspace)))
173
+ result = check_workspace(Path(args.workspace))
174
+ return 1 if result.errors else 0
175
+
176
+
177
+ def export_csv(args: argparse.Namespace) -> int:
178
+ workspace = Path(args.workspace)
179
+ result = check_workspace(workspace)
180
+ if result.errors and not args.force:
181
+ _print_check_result(result)
182
+ print("error: refusing to export CSV for invalid workspace; pass --force to export anyway", file=sys.stderr)
183
+ return 1
184
+
185
+ spec = read_json(workspace / "spec.config.json")
186
+ sample = read_json(workspace / "sample.records.json")
187
+ if not isinstance(spec, dict):
188
+ print(f"error: invalid spec config: {workspace / 'spec.config.json'}", file=sys.stderr)
189
+ return 2
190
+ if not isinstance(sample, dict):
191
+ print(f"error: invalid sample records: {workspace / 'sample.records.json'}", file=sys.stderr)
192
+ return 2
193
+
194
+ output = records_to_csv(spec, sample)
195
+ if args.output:
196
+ path = Path(args.output)
197
+ if path.exists() and not args.force:
198
+ print(f"error: {path} already exists; pass --force to overwrite", file=sys.stderr)
199
+ return 2
200
+ path.parent.mkdir(parents=True, exist_ok=True)
201
+ path.write_text(output, encoding="utf-8")
202
+ print(f"exported {path}")
203
+ else:
204
+ print(output, end="")
205
+ return 0
206
+
207
+
208
+ def self_update(args: argparse.Namespace) -> int:
209
+ root = repo_root()
210
+ plan = build_update_plan(root, method=args.method, source=args.source)
211
+ print(format_update_plan(plan, dry_run=args.dry_run))
212
+ if args.dry_run:
213
+ return 0
214
+
215
+ result = run_update(plan, force=args.force)
216
+ if result.stdout:
217
+ print(result.stdout, end="")
218
+ if result.stderr:
219
+ print(result.stderr, end="", file=sys.stderr)
220
+ if result.returncode == 0:
221
+ print("updated")
222
+ return result.returncode
223
+
224
+
225
+ def list_patterns(_: argparse.Namespace) -> int:
226
+ for pattern in load_patterns(repo_root()).values():
227
+ print(f"{pattern.name}\t{pattern.title}\t{pattern.summary}")
228
+ return 0
229
+
230
+
231
+ def show_pattern(args: argparse.Namespace) -> int:
232
+ patterns = load_patterns(repo_root())
233
+ pattern = patterns[args.pattern]
234
+ if args.files:
235
+ for path in (
236
+ pattern.readme_path,
237
+ pattern.spec_config_path,
238
+ pattern.sample_records_path,
239
+ ):
240
+ if path is not None:
241
+ print(path)
242
+ return 0
243
+
244
+ if pattern.readme_path and pattern.readme_path.exists():
245
+ print(pattern.readme_path.read_text(encoding="utf-8").rstrip())
246
+ else:
247
+ print(f"{pattern.name}: {pattern.summary}")
248
+ return 0
249
+
250
+
251
+ def render_sql(args: argparse.Namespace) -> int:
252
+ workspace = Path(args.workspace)
253
+ result = check_workspace(workspace)
254
+ if result.errors and not args.force:
255
+ _print_check_result(result)
256
+ print("error: refusing to render SQL for invalid workspace; pass --force to render anyway", file=sys.stderr)
257
+ return 1
258
+
259
+ spec = read_json(workspace / "spec.config.json")
260
+ if not isinstance(spec, dict):
261
+ print(f"error: invalid spec config: {workspace / 'spec.config.json'}", file=sys.stderr)
262
+ return 2
263
+ print(render_admin_sql(spec, app_name=args.app, operation=args.operation))
264
+ return 0
265
+
266
+
267
+ def doctor(_: argparse.Namespace) -> int:
268
+ root = repo_root()
269
+ print(f"root: {root}")
270
+ patterns = load_patterns(root)
271
+ print(f"patterns: {', '.join(patterns)}")
272
+ exit_code = 0
273
+ for pattern in patterns.values():
274
+ missing = [
275
+ str(path)
276
+ for path in (pattern.spec_config_path, pattern.sample_records_path)
277
+ if not path.exists()
278
+ ]
279
+ if missing:
280
+ exit_code = 1
281
+ for path in missing:
282
+ print(f"error: missing pattern file: {path}")
283
+ template = root / "workspaces" / "_template"
284
+ print(f"workspace template: {template}")
285
+ if not template.exists():
286
+ print(f"error: missing workspace template: {template}")
287
+ exit_code = 1
288
+ if exit_code == 0:
289
+ print("ok")
290
+ return exit_code
291
+
292
+
293
+ def build_parser() -> argparse.ArgumentParser:
294
+ parser = argparse.ArgumentParser(
295
+ prog="airlock-mcp",
296
+ description="Draft, clone, import, and check Airlock spec workspaces.",
297
+ )
298
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
299
+ subparsers = parser.add_subparsers(dest="command", required=True)
300
+
301
+ patterns = load_patterns(repo_root())
302
+
303
+ about_parser = subparsers.add_parser("about", help="Show the Airlock MCP mark and command map.")
304
+ about_parser.set_defaults(func=about)
305
+
306
+ init_repo_parser = subparsers.add_parser("init-repo", help="Prepare a specs repo for Codex and Airlock MCP.")
307
+ init_repo_parser.add_argument("path", nargs="?", default=".", help="Specs repo path. Defaults to current directory.")
308
+ init_repo_parser.add_argument("--force", action="store_true", help="Overwrite AGENTS.md and the repo-scoped skill.")
309
+ init_repo_parser.set_defaults(func=init_repo)
310
+
311
+ init_parser = subparsers.add_parser("init", help="Create a spec workspace.")
312
+ init_parser.add_argument("name", help="Workspace folder name.")
313
+ init_parser.add_argument(
314
+ "--pattern",
315
+ choices=sorted(patterns),
316
+ default="blank",
317
+ help="Starting pattern. Defaults to blank for a known process.",
318
+ )
319
+ init_parser.add_argument("--output", help="Parent directory. Defaults to ./workspaces.")
320
+ init_parser.add_argument("--force", action="store_true", help="Overwrite files.")
321
+ init_parser.set_defaults(func=init_workspace)
322
+
323
+ import_parser = subparsers.add_parser("import-spec", help="Create a workspace from a spec JSON file.")
324
+ import_parser.add_argument("source", help="JSON file with specConfig, spec_config, SPEC_CONFIG, or canonical config.")
325
+ import_parser.add_argument("name", help="Workspace folder name.")
326
+ import_parser.add_argument("--output", help="Parent directory. Defaults to ./workspaces.")
327
+ import_parser.add_argument("--force", action="store_true", help="Overwrite files.")
328
+ import_parser.set_defaults(func=import_spec)
329
+
330
+ clone_parser = subparsers.add_parser("clone", help="Clone an existing workspace under a new spec name.")
331
+ clone_parser.add_argument("source_workspace", help="Existing workspace directory.")
332
+ clone_parser.add_argument("name", help="New workspace folder name.")
333
+ clone_parser.add_argument("--output", help="Parent directory. Defaults to ./workspaces.")
334
+ clone_parser.add_argument("--force", action="store_true", help="Overwrite files.")
335
+ clone_parser.set_defaults(func=clone)
336
+
337
+ check_parser = subparsers.add_parser("check", help="Check a spec workspace.")
338
+ check_parser.add_argument("workspace", help="Workspace directory.")
339
+ check_parser.set_defaults(func=check)
340
+
341
+ list_workspaces_parser = subparsers.add_parser("list-workspaces", help="List local spec workspaces.")
342
+ list_workspaces_parser.add_argument("--root", help="Workspace root. Defaults to ./workspaces.")
343
+ list_workspaces_parser.add_argument("--all", action="store_true", help="Include archived workspaces.")
344
+ list_workspaces_parser.add_argument("--paths", action="store_true", help="Print only workspace paths.")
345
+ list_workspaces_parser.set_defaults(func=list_workspaces)
346
+
347
+ archive_parser = subparsers.add_parser("archive", help="Move a workspace into _archive.")
348
+ archive_parser.add_argument("workspace", help="Workspace directory to archive.")
349
+ archive_parser.add_argument("--archive-root", help="Archive directory. Defaults to <workspace-parent>/_archive.")
350
+ archive_parser.set_defaults(func=archive)
351
+
352
+ restore_parser = subparsers.add_parser("restore", help="Restore a workspace from _archive.")
353
+ restore_parser.add_argument("archived_workspace", help="Archived workspace directory to restore.")
354
+ restore_parser.add_argument("--output", help="Restore parent directory. Defaults to archive parent.")
355
+ restore_parser.set_defaults(func=restore)
356
+
357
+ rename_parser = subparsers.add_parser("rename", help="Rename a workspace and retitle its spec identity.")
358
+ rename_parser.add_argument("workspace", help="Workspace directory to rename.")
359
+ rename_parser.add_argument("name", help="New workspace folder name.")
360
+ rename_parser.add_argument("--output", help="Target parent directory. Defaults to the current parent.")
361
+ rename_parser.add_argument(
362
+ "--keep-spec-identity",
363
+ action="store_true",
364
+ help="Move the folder without changing spec_name, spec_alias, or sample spec_name.",
365
+ )
366
+ rename_parser.set_defaults(func=rename)
367
+
368
+ summary_parser = subparsers.add_parser("summary", help="Summarize a spec workspace for session re-entry.")
369
+ summary_parser.add_argument("workspace", help="Workspace directory.")
370
+ summary_parser.set_defaults(func=summary)
371
+
372
+ next_parser = subparsers.add_parser("next", help="Show the next safe action for a workspace.")
373
+ next_parser.add_argument("workspace", help="Workspace directory.")
374
+ next_parser.set_defaults(func=next_action)
375
+
376
+ export_parser = subparsers.add_parser("export-csv", help="Render sample records as Airlock-ready CSV.")
377
+ export_parser.add_argument("workspace", help="Workspace directory.")
378
+ export_parser.add_argument("--output", help="Write CSV to a file instead of stdout.")
379
+ export_parser.add_argument("--force", action="store_true", help="Export despite local errors or overwrite output.")
380
+ export_parser.set_defaults(func=export_csv)
381
+
382
+ list_parser = subparsers.add_parser("list-patterns", help="List bundled patterns.")
383
+ list_parser.set_defaults(func=list_patterns)
384
+
385
+ show_parser = subparsers.add_parser("show-pattern", help="Show a pattern README or file paths.")
386
+ show_parser.add_argument("pattern", choices=sorted(patterns))
387
+ show_parser.add_argument("--files", action="store_true", help="Print pattern file paths instead of README.")
388
+ show_parser.set_defaults(func=show_pattern)
389
+
390
+ update_parser = subparsers.add_parser("self-update", help="Update Airlock MCP from git or pip.")
391
+ update_parser.add_argument(
392
+ "--method",
393
+ choices=("auto", "git", "pip"),
394
+ default="auto",
395
+ help="Update method. Defaults to git for checkouts and pip otherwise.",
396
+ )
397
+ update_parser.add_argument(
398
+ "--source",
399
+ default="git+https://github.com/reunionstudio/airlock-mcp.git",
400
+ help="pip source used for package installs.",
401
+ )
402
+ update_parser.add_argument("--dry-run", action="store_true", help="Print the update command without running it.")
403
+ update_parser.add_argument("--force", action="store_true", help="Run git update despite uncommitted changes.")
404
+ update_parser.set_defaults(func=self_update)
405
+
406
+ sql_parser = subparsers.add_parser("render-sql", help="Render validate-only admin SQL for a workspace.")
407
+ sql_parser.add_argument("workspace", help="Workspace directory.")
408
+ sql_parser.add_argument("--app", default="airlock", help="Installed app name. Defaults to airlock.")
409
+ sql_parser.add_argument(
410
+ "--operation",
411
+ choices=("create", "alter"),
412
+ default="create",
413
+ help="Procedure shape to render. Defaults to create.",
414
+ )
415
+ sql_parser.add_argument("--force", action="store_true", help="Render even if local check has errors.")
416
+ sql_parser.set_defaults(func=render_sql)
417
+
418
+ doctor_parser = subparsers.add_parser("doctor", help="Check repo-level Airlock MCP assets.")
419
+ doctor_parser.set_defaults(func=doctor)
420
+
421
+ return parser
422
+
423
+
424
+ def main(argv: list[str] | None = None) -> int:
425
+ parser = build_parser()
426
+ args = parser.parse_args(argv)
427
+ try:
428
+ return args.func(args)
429
+ except FileExistsError as exc:
430
+ target = exc.filename or (exc.args[0] if exc.args else "unknown")
431
+ print(f"error: {target} already exists; pass --force to overwrite", file=sys.stderr)
432
+ return 2
433
+ except FileNotFoundError as exc:
434
+ target = exc.filename or (exc.args[0] if exc.args else "unknown")
435
+ print(f"error: not found: {target}", file=sys.stderr)
436
+ return 2
437
+ except NotADirectoryError as exc:
438
+ target = exc.filename or (exc.args[0] if exc.args else "unknown")
439
+ print(f"error: not a directory: {target}", file=sys.stderr)
440
+ return 2
441
+ except ValueError as exc:
442
+ print(f"error: {exc}", file=sys.stderr)
443
+ return 2
444
+ except KeyboardInterrupt:
445
+ print("interrupted", file=sys.stderr)
446
+ return 130
447
+
448
+
449
+ if __name__ == "__main__":
450
+ raise SystemExit(main())
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .models import Finding
8
+
9
+
10
+ def read_json(path: Path, findings: list[Finding] | None = None) -> Any | None:
11
+ try:
12
+ with path.open("r", encoding="utf-8") as handle:
13
+ return json.load(handle)
14
+ except FileNotFoundError:
15
+ if findings is not None:
16
+ findings.append(Finding("error", str(path), "Missing required JSON file."))
17
+ except json.JSONDecodeError as exc:
18
+ if findings is not None:
19
+ findings.append(
20
+ Finding("error", str(path), f"Invalid JSON at line {exc.lineno}: {exc.msg}.")
21
+ )
22
+ return None
23
+
24
+
25
+ def write_json(path: Path, value: Any, *, force: bool = False) -> None:
26
+ if path.exists() and not force:
27
+ raise FileExistsError(path)
28
+ path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8")
29
+
30
+
31
+ def read_text(path: Path) -> str:
32
+ return path.read_text(encoding="utf-8")
33
+
34
+
35
+ def write_text(path: Path, value: str, *, force: bool = False) -> None:
36
+ if path.exists() and not force:
37
+ raise FileExistsError(path)
38
+ path.write_text(value, encoding="utf-8")
39
+
40
+
41
+ def coerce_json_object(value: Any) -> dict[str, Any] | None:
42
+ current = value
43
+ for _ in range(5):
44
+ if isinstance(current, dict):
45
+ return current
46
+ if isinstance(current, str):
47
+ stripped = current.strip()
48
+ if not stripped:
49
+ return None
50
+ try:
51
+ current = json.loads(stripped)
52
+ except json.JSONDecodeError:
53
+ return None
54
+ continue
55
+ return None
56
+ return None