@softspark/ai-toolkit 2.4.1 → 2.6.1

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 (48) hide show
  1. package/AGENTS.md +33 -20
  2. package/CHANGELOG.md +57 -0
  3. package/README.md +29 -13
  4. package/app/.claude-plugin/plugin.json +3 -2
  5. package/app/ARCHITECTURE.md +11 -0
  6. package/app/agents/code-reviewer.md +6 -7
  7. package/app/agents/frontend-specialist.md +33 -2
  8. package/app/agents/seo-specialist.md +1 -1
  9. package/app/personas/frontend-lead.md +48 -5
  10. package/app/skills/a11y-validate/SKILL.md +377 -0
  11. package/app/skills/a11y-validate/reference/aria-patterns.md +259 -0
  12. package/app/skills/a11y-validate/reference/eaa-compliance.md +252 -0
  13. package/app/skills/a11y-validate/reference/mobile-eaa.md +329 -0
  14. package/app/skills/a11y-validate/reference/wcag-2-1-aa.md +285 -0
  15. package/app/skills/a11y-validate/reference/wcag-2-2-aa.md +221 -0
  16. package/app/skills/a11y-validate/scripts/a11y-scanner.py +639 -0
  17. package/app/skills/clean-code/reference/python.md +3 -3
  18. package/app/skills/design-engineering/SKILL.md +2 -5
  19. package/app/skills/hipaa-validate/SKILL.md +39 -23
  20. package/app/skills/hipaa-validate/scripts/hipaa_scan.py +64 -7
  21. package/app/skills/review/SKILL.md +30 -6
  22. package/app/skills/seo-validate/SKILL.md +460 -0
  23. package/app/skills/seo-validate/reference/core-web-vitals.md +445 -0
  24. package/app/skills/seo-validate/reference/geo-aeo-patterns.md +259 -0
  25. package/app/skills/seo-validate/reference/geo-guidelines.md +248 -0
  26. package/app/skills/seo-validate/reference/schema-types.md +465 -0
  27. package/app/skills/seo-validate/reference/spa-ssg-patterns.md +351 -0
  28. package/app/skills/seo-validate/reference/w3c-guidelines.md +289 -0
  29. package/app/skills/seo-validate/scripts/seo-scanner.py +549 -0
  30. package/bin/ai-toolkit.js +24 -9
  31. package/kb/reference/architecture-overview.md +1 -1
  32. package/kb/reference/comparison.md +1 -1
  33. package/kb/reference/opencode-compatibility.md +161 -0
  34. package/kb/reference/skills-catalog.md +3 -1
  35. package/llms-full.txt +177 -6
  36. package/llms.txt +1 -0
  37. package/manifest.json +3 -3
  38. package/package.json +6 -3
  39. package/scripts/config_cli.py +4 -10
  40. package/scripts/doctor.py +3 -3
  41. package/scripts/generate_opencode.py +117 -0
  42. package/scripts/generate_opencode_agents.py +126 -0
  43. package/scripts/generate_opencode_commands.py +158 -0
  44. package/scripts/generate_opencode_json.py +133 -0
  45. package/scripts/generate_opencode_plugin.py +169 -0
  46. package/scripts/install_steps/ai_tools.py +117 -1
  47. package/scripts/install_steps/install_state.py +1 -1
  48. package/scripts/plugin.py +1 -1
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env python3
2
+ """Generate AGENTS.md content for opencode (https://opencode.ai).
3
+
4
+ opencode reads AGENTS.md using the same convention as OpenAI Codex CLI.
5
+ The generated file is safe for both editors to consume from the same
6
+ project root — each injects into its own marker-wrapped block.
7
+
8
+ Usage: ./scripts/generate_opencode.py > AGENTS.md
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
16
+ from codex_skill_adapter import codex_skill_description
17
+ from emission import (
18
+ agents_dir,
19
+ skills_dir,
20
+ generate_quality_standards,
21
+ generate_workflow_guidelines,
22
+ print_toolkit_end,
23
+ print_toolkit_start,
24
+ )
25
+ from frontmatter import frontmatter_field
26
+ from paths import RULES_DIR
27
+
28
+
29
+ def _emit_agents() -> str:
30
+ """Emit agents as opencode subagent bullets."""
31
+ lines: list[str] = []
32
+ for agent_file in sorted(agents_dir.glob("*.md")):
33
+ name = frontmatter_field(agent_file, "name")
34
+ description = frontmatter_field(agent_file, "description")
35
+ if not name or not description:
36
+ continue
37
+ lines.append(f"- **{name}**: {description}")
38
+ return "\n".join(lines)
39
+
40
+
41
+ def _emit_skills() -> str:
42
+ """Emit user-invocable skills as opencode command bullets.
43
+
44
+ Adapts Claude orchestration-only descriptions via the Codex adapter —
45
+ opencode, like Codex, lacks Claude's Agent/Team/Task primitives so the
46
+ same translation layer applies.
47
+ """
48
+ lines: list[str] = []
49
+ for skill_dir in sorted(skills_dir.iterdir()):
50
+ if skill_dir.name.startswith("_"):
51
+ continue
52
+ skill_file = skill_dir / "SKILL.md"
53
+ if not skill_file.is_file():
54
+ continue
55
+ name = frontmatter_field(skill_file, "name")
56
+ description = codex_skill_description(skill_file)
57
+ if not name or not description:
58
+ continue
59
+ lines.append(f"- **{name}**: {description}")
60
+ return "\n".join(lines)
61
+
62
+
63
+ def main() -> None:
64
+ print_toolkit_start()
65
+
66
+ print("# AI Toolkit — opencode Configuration")
67
+ print()
68
+ print(
69
+ "Shared AI development toolkit with specialized subagents,"
70
+ " opencode-compatible commands, quality hooks, and a safety constitution."
71
+ )
72
+ print()
73
+ print(
74
+ "opencode reads this file at session start. Subagents are also"
75
+ " installed under `.opencode/agents/` and slash commands under"
76
+ " `.opencode/commands/` for native `@` and `/` autocomplete."
77
+ )
78
+
79
+ # Agents
80
+ print()
81
+ print("## Available Subagents")
82
+ print()
83
+ print("Invoke with `@<name>` in opencode, or delegate via `spawn_agent`:")
84
+ print()
85
+ print(_emit_agents())
86
+
87
+ # Skills / slash commands
88
+ print()
89
+ print("## Available Commands")
90
+ print()
91
+ print("Invoke with `/<name>` in opencode. Knowledge-only skills load automatically:")
92
+ print()
93
+ print(_emit_skills())
94
+
95
+ # Guidelines
96
+ print()
97
+ print(generate_quality_standards())
98
+ print()
99
+ print(generate_workflow_guidelines())
100
+
101
+ print_toolkit_end()
102
+
103
+ # Registered custom rules from ~/.softspark/ai-toolkit/rules/
104
+ if RULES_DIR.is_dir():
105
+ for rule_file in sorted(RULES_DIR.glob("*.md")):
106
+ rule_name = rule_file.stem
107
+ print()
108
+ print(f"<!-- TOOLKIT:{rule_name} START -->")
109
+ print("<!-- Auto-injected by ai-toolkit. Re-run to update. -->")
110
+ print()
111
+ print(rule_file.read_text(encoding="utf-8").rstrip())
112
+ print()
113
+ print(f"<!-- TOOLKIT:{rule_name} END -->")
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env python3
2
+ """Generate .opencode/agents/*.md files for opencode (https://opencode.ai).
3
+
4
+ Each ai-toolkit agent becomes an opencode subagent with frontmatter:
5
+
6
+ ---
7
+ description: "..."
8
+ mode: subagent
9
+ ---
10
+
11
+ <body from agent file>
12
+
13
+ Generated files are prefixed ``ai-toolkit-`` so they never collide with
14
+ user-authored opencode agents and can be cleanly removed on uninstall.
15
+
16
+ Usage:
17
+ python3 scripts/generate_opencode_agents.py [target-dir]
18
+
19
+ Writes files to target-dir/.opencode/agents/.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
27
+ from emission import agents_dir
28
+ from frontmatter import frontmatter_field
29
+
30
+ AGENT_PREFIX = "ai-toolkit-"
31
+
32
+
33
+ def _agent_body(agent_file: Path) -> str:
34
+ """Return the markdown body of an agent file (content after frontmatter)."""
35
+ text = agent_file.read_text(encoding="utf-8")
36
+ if not text.startswith("---"):
37
+ return text.strip() + "\n"
38
+ # Skip first frontmatter block
39
+ parts = text.split("---", 2)
40
+ if len(parts) < 3:
41
+ return text.strip() + "\n"
42
+ return parts[2].lstrip("\n")
43
+
44
+
45
+ def _render_opencode_agent(agent_file: Path) -> str:
46
+ """Render a single opencode subagent .md file from an ai-toolkit agent."""
47
+ name = frontmatter_field(agent_file, "name")
48
+ description = frontmatter_field(agent_file, "description")
49
+ model = frontmatter_field(agent_file, "model")
50
+ color = frontmatter_field(agent_file, "color")
51
+
52
+ # Escape description for YAML quoted string
53
+ safe_desc = description.replace('"', "'")
54
+
55
+ lines: list[str] = ["---"]
56
+ lines.append(f'description: "{safe_desc}"')
57
+ lines.append("mode: subagent")
58
+ # opencode requires `provider/model-id` for the `model` field. ai-toolkit
59
+ # only stores a short alias (opus/sonnet/haiku) which is not mappable
60
+ # without assuming a provider, so we deliberately omit it — opencode falls
61
+ # back to the user's `default_agent` / top-level `model` config.
62
+ _ = model # intentionally unused
63
+ if color:
64
+ lines.append(f"color: {color}")
65
+ lines.append("---")
66
+ lines.append("")
67
+ body = _agent_body(agent_file).rstrip()
68
+ if body:
69
+ lines.append(body)
70
+ lines.append("")
71
+ return "\n".join(lines)
72
+
73
+
74
+ def _cleanup_stale(agents_out: Path) -> int:
75
+ """Remove stale ai-toolkit-* agent files whose source no longer exists."""
76
+ if not agents_out.is_dir():
77
+ return 0
78
+ removed = 0
79
+ for f in sorted(agents_out.glob(f"{AGENT_PREFIX}*.md")):
80
+ source_name = f.stem[len(AGENT_PREFIX):]
81
+ source = agents_dir / f"{source_name}.md"
82
+ if not source.is_file():
83
+ f.unlink()
84
+ removed += 1
85
+ return removed
86
+
87
+
88
+ def generate(
89
+ target_dir: Path, config_root: Path | None = None
90
+ ) -> tuple[int, int]:
91
+ """Write opencode agent files and return (written, removed_stale).
92
+
93
+ By default writes to ``target_dir/.opencode/agents/`` (project-local).
94
+ Pass ``config_root=~/.config/opencode`` for the global layout, which
95
+ lives directly under ``agents/`` (no ``.opencode/`` prefix).
96
+ """
97
+ base = config_root if config_root is not None else target_dir / ".opencode"
98
+ agents_out = base / "agents"
99
+ agents_out.mkdir(parents=True, exist_ok=True)
100
+
101
+ written = 0
102
+ for agent_file in sorted(agents_dir.glob("*.md")):
103
+ name = frontmatter_field(agent_file, "name")
104
+ description = frontmatter_field(agent_file, "description")
105
+ if not name or not description:
106
+ continue
107
+ out_path = agents_out / f"{AGENT_PREFIX}{name}.md"
108
+ out_path.write_text(_render_opencode_agent(agent_file), encoding="utf-8")
109
+ written += 1
110
+
111
+ removed = _cleanup_stale(agents_out)
112
+ return written, removed
113
+
114
+
115
+ def main() -> None:
116
+ target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
117
+ written, removed = generate(target)
118
+ msg = f"Generated: .opencode/agents/ ({written} agents"
119
+ if removed:
120
+ msg += f", {removed} stale removed"
121
+ msg += ")"
122
+ print(msg)
123
+
124
+
125
+ if __name__ == "__main__":
126
+ main()
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env python3
2
+ """Generate .opencode/commands/*.md files for opencode (https://opencode.ai).
3
+
4
+ User-invocable skills become opencode slash commands. Knowledge skills
5
+ (``user-invocable: false``) are excluded — they load automatically via
6
+ AGENTS.md context instead of `/` invocation.
7
+
8
+ Generated commands use the required ``template`` frontmatter field.
9
+ Files are prefixed ``ai-toolkit-`` for clean uninstall.
10
+
11
+ opencode commands: https://opencode.ai/docs/commands/
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
19
+ from codex_skill_adapter import build_codex_skill_text, is_codex_adapted_skill
20
+ from emission import skills_dir
21
+ from frontmatter import frontmatter_field
22
+
23
+ COMMAND_PREFIX = "ai-toolkit-"
24
+
25
+
26
+ def _skill_body(skill_file: Path) -> str:
27
+ """Return the markdown body of a skill (content after frontmatter).
28
+
29
+ For skills that rely on Claude-only orchestration primitives, route
30
+ through the Codex adapter so `Agent`/`TeamCreate`/`TaskCreate` get
31
+ rewritten to opencode-compatible delegation guidance. The adapter's
32
+ output is compatible with opencode's subagent model (``spawn_agent``
33
+ conventions, plan tracking) because both lack Claude primitives.
34
+ """
35
+ if is_codex_adapted_skill(skill_file):
36
+ adapted = build_codex_skill_text(skill_file)
37
+ # Strip the adapted frontmatter — we emit our own below
38
+ parts = adapted.split("---", 2)
39
+ return parts[2].lstrip("\n") if len(parts) >= 3 else adapted
40
+
41
+ text = skill_file.read_text(encoding="utf-8")
42
+ if not text.startswith("---"):
43
+ return text
44
+ parts = text.split("---", 2)
45
+ return parts[2].lstrip("\n") if len(parts) >= 3 else text
46
+
47
+
48
+ def _render_opencode_command(skill_file: Path) -> str:
49
+ """Render a single opencode command .md file from a user-invocable skill."""
50
+ name = frontmatter_field(skill_file, "name")
51
+ description = frontmatter_field(skill_file, "description")
52
+ agent_field = frontmatter_field(skill_file, "agent")
53
+ # opencode does NOT read "body" — the prompt is entirely in the `template`
54
+ # frontmatter field. We embed the SKILL.md body as the template using a
55
+ # YAML block scalar (``|``) which preserves newlines without escaping.
56
+ body = _skill_body(skill_file).rstrip()
57
+
58
+ lines: list[str] = ["---"]
59
+ if description:
60
+ safe_desc = description.replace('"', "'")
61
+ lines.append(f'description: "{safe_desc}"')
62
+ if agent_field:
63
+ # opencode accepts an `agent` frontmatter field pointing at a subagent
64
+ lines.append(f"agent: {_map_agent_name(agent_field)}")
65
+ lines.append("template: |")
66
+ for tpl_line in body.splitlines() or [""]:
67
+ lines.append(f" {tpl_line}" if tpl_line else " ")
68
+ lines.append("---")
69
+ lines.append("")
70
+ return "\n".join(lines)
71
+
72
+
73
+ def _map_agent_name(value: str) -> str:
74
+ """Map ai-toolkit agent names to opencode subagent names.
75
+
76
+ Our opencode agents are installed with the ``ai-toolkit-`` prefix by
77
+ ``generate_opencode_agents.py``, so rewrite here for consistency.
78
+ """
79
+ value = value.strip()
80
+ if not value:
81
+ return value
82
+ if value.startswith(COMMAND_PREFIX):
83
+ return value
84
+ return f"{COMMAND_PREFIX}{value}"
85
+
86
+
87
+ def _is_user_invocable(skill_file: Path) -> bool:
88
+ """Return True if the skill should be exposed as a `/` command."""
89
+ invocable = frontmatter_field(skill_file, "user-invocable")
90
+ if invocable:
91
+ return invocable.lower() not in ("false", "0", "no")
92
+ # Absence + `disable-model-invocation: true` = task skill (user-invocable)
93
+ disable_model = frontmatter_field(skill_file, "disable-model-invocation")
94
+ if disable_model and disable_model.lower() in ("true", "1", "yes"):
95
+ return True
96
+ # Default: NOT invocable — avoids exposing knowledge skills as commands
97
+ return False
98
+
99
+
100
+ def _cleanup_stale(commands_out: Path) -> int:
101
+ """Remove stale ai-toolkit-* command files whose source no longer exists or is no longer invocable."""
102
+ if not commands_out.is_dir():
103
+ return 0
104
+ removed = 0
105
+ for f in sorted(commands_out.glob(f"{COMMAND_PREFIX}*.md")):
106
+ source_name = f.stem[len(COMMAND_PREFIX):]
107
+ source = skills_dir / source_name / "SKILL.md"
108
+ if not source.is_file() or not _is_user_invocable(source):
109
+ f.unlink()
110
+ removed += 1
111
+ return removed
112
+
113
+
114
+ def generate(
115
+ target_dir: Path, config_root: Path | None = None
116
+ ) -> tuple[int, int]:
117
+ """Write opencode command files and return (written, removed_stale).
118
+
119
+ By default writes to ``target_dir/.opencode/commands/`` (project-local).
120
+ Pass ``config_root=~/.config/opencode`` for the global layout, which
121
+ lives directly under ``commands/`` (no ``.opencode/`` prefix).
122
+ """
123
+ base = config_root if config_root is not None else target_dir / ".opencode"
124
+ commands_out = base / "commands"
125
+ commands_out.mkdir(parents=True, exist_ok=True)
126
+
127
+ written = 0
128
+ for skill_dir in sorted(skills_dir.iterdir()):
129
+ if skill_dir.name.startswith("_"):
130
+ continue
131
+ skill_file = skill_dir / "SKILL.md"
132
+ if not skill_file.is_file():
133
+ continue
134
+ if not _is_user_invocable(skill_file):
135
+ continue
136
+ name = frontmatter_field(skill_file, "name")
137
+ if not name:
138
+ continue
139
+ out_path = commands_out / f"{COMMAND_PREFIX}{name}.md"
140
+ out_path.write_text(_render_opencode_command(skill_file), encoding="utf-8")
141
+ written += 1
142
+
143
+ removed = _cleanup_stale(commands_out)
144
+ return written, removed
145
+
146
+
147
+ def main() -> None:
148
+ target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
149
+ written, removed = generate(target)
150
+ msg = f"Generated: .opencode/commands/ ({written} commands"
151
+ if removed:
152
+ msg += f", {removed} stale removed"
153
+ msg += ")"
154
+ print(msg)
155
+
156
+
157
+ if __name__ == "__main__":
158
+ main()
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env python3
2
+ """Merge MCP servers from .mcp.json into opencode.json for opencode.
3
+
4
+ opencode MCP config: https://opencode.ai/docs/mcp-servers/
5
+
6
+ Schema per server:
7
+ Local: {"type": "local", "command": [...], "enabled": true, "environment": {...}}
8
+ Remote: {"type": "remote", "url": "...", "enabled": true, "headers": {...}}
9
+
10
+ Our canonical source is .mcp.json (Claude-style) with:
11
+ {"mcpServers": {"name": {"command": "...", "args": [...], "env": {...}}}}
12
+ or: {"name": {"url": "..."}} for remote.
13
+
14
+ We translate into opencode's shape and merge under the ``mcp`` key.
15
+ All non-``mcp`` keys are preserved verbatim — idempotent on re-run.
16
+
17
+ Usage:
18
+ python3 scripts/generate_opencode_json.py [target-dir]
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import copy
23
+ import json
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ SCHEMA_URL = "https://opencode.ai/config.json"
28
+
29
+
30
+ def _load_json(path: Path) -> dict:
31
+ if not path.is_file():
32
+ return {}
33
+ with open(path, encoding="utf-8") as f:
34
+ data = json.load(f)
35
+ if not isinstance(data, dict):
36
+ raise ValueError(f"{path} must contain a JSON object")
37
+ return data
38
+
39
+
40
+ def _write_json(path: Path, data: dict) -> None:
41
+ path.parent.mkdir(parents=True, exist_ok=True)
42
+ with open(path, "w", encoding="utf-8") as f:
43
+ json.dump(data, f, indent=2)
44
+ f.write("\n")
45
+
46
+
47
+ def _translate_server(name: str, server: dict) -> dict:
48
+ """Translate a Claude-style server entry into opencode's shape."""
49
+ out: dict = {}
50
+ if "url" in server:
51
+ out["type"] = "remote"
52
+ out["url"] = server["url"]
53
+ if "headers" in server and isinstance(server["headers"], dict):
54
+ out["headers"] = copy.deepcopy(server["headers"])
55
+ elif "command" in server:
56
+ out["type"] = "local"
57
+ cmd = server["command"]
58
+ args = server.get("args") or []
59
+ if isinstance(cmd, list):
60
+ out["command"] = list(cmd) + list(args)
61
+ else:
62
+ out["command"] = [cmd, *args]
63
+ env = server.get("env") or server.get("environment")
64
+ if isinstance(env, dict) and env:
65
+ out["environment"] = copy.deepcopy(env)
66
+ else:
67
+ # Unknown shape — preserve as-is so opencode can report the error
68
+ out = copy.deepcopy(server)
69
+ out.setdefault("type", "local")
70
+
71
+ out.setdefault("enabled", True)
72
+ if "timeout" in server:
73
+ out["timeout"] = server["timeout"]
74
+ return out
75
+
76
+
77
+ def _read_mcp_servers(project_dir: Path) -> dict:
78
+ """Read canonical .mcp.json and return the servers map.
79
+
80
+ Returns {} when no .mcp.json exists (non-fatal).
81
+ """
82
+ config = project_dir / ".mcp.json"
83
+ if not config.is_file():
84
+ return {}
85
+ data = _load_json(config)
86
+ servers = data.get("mcpServers", {})
87
+ if not isinstance(servers, dict):
88
+ return {}
89
+ return servers
90
+
91
+
92
+ def merge_into_opencode_json(
93
+ target_dir: Path, output_path: Path | None = None
94
+ ) -> tuple[Path, int]:
95
+ """Merge .mcp.json servers into an opencode.json file.
96
+
97
+ Reads ``target_dir/.mcp.json`` as the canonical source. By default
98
+ writes to ``target_dir/opencode.json`` (project-local). Pass
99
+ ``output_path=~/.config/opencode/opencode.json`` for the global layout.
100
+
101
+ Returns (path, server_count). If no .mcp.json exists, still ensures
102
+ the output has the $schema key set (creates a minimal file).
103
+ """
104
+ servers = _read_mcp_servers(target_dir)
105
+ path = output_path if output_path is not None else target_dir / "opencode.json"
106
+ data = _load_json(path)
107
+ data.setdefault("$schema", SCHEMA_URL)
108
+
109
+ if servers:
110
+ bucket = data.setdefault("mcp", {})
111
+ if not isinstance(bucket, dict):
112
+ raise ValueError(f"{path} has invalid 'mcp' data (expected object)")
113
+ for name, entry in servers.items():
114
+ if not isinstance(entry, dict):
115
+ continue
116
+ bucket[name] = _translate_server(name, entry)
117
+
118
+ _write_json(path, data)
119
+ return path, len(servers)
120
+
121
+
122
+ def main() -> None:
123
+ target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
124
+ path, count = merge_into_opencode_json(target)
125
+ rel = path.relative_to(target) if path.is_relative_to(target) else path
126
+ if count:
127
+ print(f"Generated: {rel} ({count} MCP server(s) merged)")
128
+ else:
129
+ print(f"Generated: {rel} (no MCP servers; .mcp.json not found)")
130
+
131
+
132
+ if __name__ == "__main__":
133
+ main()