flow2skill 0.1.0__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.
- flow2skill/__init__.py +6 -0
- flow2skill/__main__.py +3 -0
- flow2skill/cli.py +212 -0
- flow2skill/exporter.py +359 -0
- flow2skill/model.py +398 -0
- flow2skill/parser.py +407 -0
- flow2skill/recorder.py +298 -0
- flow2skill/replay.py +153 -0
- flow2skill/server.py +335 -0
- flow2skill/ui/demo_form.html +36 -0
- flow2skill/ui/index.html +274 -0
- flow2skill-0.1.0.dist-info/METADATA +233 -0
- flow2skill-0.1.0.dist-info/RECORD +17 -0
- flow2skill-0.1.0.dist-info/WHEEL +5 -0
- flow2skill-0.1.0.dist-info/entry_points.txt +2 -0
- flow2skill-0.1.0.dist-info/licenses/LICENSE +21 -0
- flow2skill-0.1.0.dist-info/top_level.txt +1 -0
flow2skill/__init__.py
ADDED
flow2skill/__main__.py
ADDED
flow2skill/cli.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from importlib.metadata import version
|
|
8
|
+
from importlib.resources import files
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .exporter import write_bundle
|
|
13
|
+
from .model import FlowValidationError, Workflow
|
|
14
|
+
from .parser import parse_codegen_file
|
|
15
|
+
from .recorder import DEFAULT_CODEGEN_VERSION, compile_source, record_blocking
|
|
16
|
+
from .replay import replay
|
|
17
|
+
|
|
18
|
+
DEFAULT_WORKSPACES = Path.home() / "Flow2SkillWorkspaces"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
DEMO_CAPTURE_VALUE = "synthetic-demo-value-7Q9X"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def demo_recording() -> str:
|
|
25
|
+
fixture = Path(str(files("flow2skill").joinpath("ui/demo_form.html"))).resolve().as_uri()
|
|
26
|
+
return f"""from playwright.sync_api import Page, expect
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_agent_release_gate(page: Page) -> None:
|
|
30
|
+
page.goto({fixture!r})
|
|
31
|
+
page.get_by_label("API token").fill({DEMO_CAPTURE_VALUE!r})
|
|
32
|
+
page.get_by_role("button", name="Validate workflow").click()
|
|
33
|
+
expect(page.get_by_text("Ready for deterministic replay", exact=True)).to_be_visible()
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
38
|
+
parser = argparse.ArgumentParser(
|
|
39
|
+
prog="flow2skill",
|
|
40
|
+
description="Demonstrate a browser flow once; compile it into an agent skill and a test.",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument("--version", action="version", version=f"Flow2Skill {__version__}")
|
|
43
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
44
|
+
|
|
45
|
+
record = commands.add_parser(
|
|
46
|
+
"record", help="Open Playwright codegen and capture a browser flow"
|
|
47
|
+
)
|
|
48
|
+
record.add_argument("url")
|
|
49
|
+
record.add_argument("--name", required=True)
|
|
50
|
+
record.add_argument("--intent", default="Replay the demonstrated browser workflow reliably.")
|
|
51
|
+
record.add_argument("--success", default="The recorded assertions pass.")
|
|
52
|
+
record.add_argument("--success-text")
|
|
53
|
+
record.add_argument("--out", type=Path, default=DEFAULT_WORKSPACES)
|
|
54
|
+
record.add_argument("--channel", default="chrome")
|
|
55
|
+
record.add_argument(
|
|
56
|
+
"--protect-inputs",
|
|
57
|
+
action=argparse.BooleanOptionalAction,
|
|
58
|
+
default=True,
|
|
59
|
+
help="Replace typed and asserted values with environment variables (default: enabled)",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
compile_cmd = commands.add_parser("compile", help="Compile Playwright Python codegen output")
|
|
63
|
+
compile_cmd.add_argument("recording", type=Path)
|
|
64
|
+
compile_cmd.add_argument("--name", required=True)
|
|
65
|
+
compile_cmd.add_argument(
|
|
66
|
+
"--intent", default="Replay the demonstrated browser workflow reliably."
|
|
67
|
+
)
|
|
68
|
+
compile_cmd.add_argument("--success", default="The recorded assertions pass.")
|
|
69
|
+
compile_cmd.add_argument("--success-text")
|
|
70
|
+
compile_cmd.add_argument("--out", type=Path, required=True)
|
|
71
|
+
compile_cmd.add_argument(
|
|
72
|
+
"--protect-inputs",
|
|
73
|
+
action=argparse.BooleanOptionalAction,
|
|
74
|
+
default=True,
|
|
75
|
+
help="Replace typed and asserted values with environment variables (default: enabled)",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
export = commands.add_parser("export", help="Regenerate artifacts from flow.json")
|
|
79
|
+
export.add_argument("flow", type=Path)
|
|
80
|
+
export.add_argument("--out", type=Path, required=True)
|
|
81
|
+
|
|
82
|
+
inspect = commands.add_parser("inspect", help="Print a safe execution plan")
|
|
83
|
+
inspect.add_argument("flow", type=Path)
|
|
84
|
+
|
|
85
|
+
replay_cmd = commands.add_parser("replay", help="Dry-run or execute a compiled workflow")
|
|
86
|
+
replay_cmd.add_argument("flow", type=Path)
|
|
87
|
+
replay_cmd.add_argument("--live", action="store_true")
|
|
88
|
+
replay_cmd.add_argument("--headed", action="store_true")
|
|
89
|
+
replay_cmd.add_argument("--allow-side-effects", action="store_true")
|
|
90
|
+
replay_cmd.add_argument("--channel")
|
|
91
|
+
replay_cmd.add_argument("--evidence-dir", type=Path)
|
|
92
|
+
|
|
93
|
+
demo = commands.add_parser("demo", help="Generate an executable local sample bundle")
|
|
94
|
+
demo.add_argument("--out", type=Path, default=Path("flow2skill-demo"))
|
|
95
|
+
|
|
96
|
+
commands.add_parser("doctor", help="Check recorder and replay prerequisites")
|
|
97
|
+
|
|
98
|
+
studio = commands.add_parser("studio", help="Launch the local Flow2Skill Studio UI")
|
|
99
|
+
studio.add_argument("--host", default="127.0.0.1")
|
|
100
|
+
studio.add_argument("--port", type=int, default=8765)
|
|
101
|
+
studio.add_argument("--workspace-root", type=Path, default=DEFAULT_WORKSPACES)
|
|
102
|
+
studio.add_argument("--no-open", action="store_true")
|
|
103
|
+
return parser
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def doctor() -> int:
|
|
107
|
+
checks: list[tuple[str, bool, str]] = []
|
|
108
|
+
checks.append(("Python", sys.version_info >= (3, 10), sys.version.split()[0]))
|
|
109
|
+
playwright_version = version("playwright")
|
|
110
|
+
checks.append(
|
|
111
|
+
(
|
|
112
|
+
"Python Playwright",
|
|
113
|
+
playwright_version == DEFAULT_CODEGEN_VERSION,
|
|
114
|
+
f"{playwright_version} (expected {DEFAULT_CODEGEN_VERSION})",
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
checks.append(("Node.js", shutil.which("node") is not None, shutil.which("node") or "missing"))
|
|
118
|
+
checks.append(("npx", shutil.which("npx") is not None, shutil.which("npx") or "missing"))
|
|
119
|
+
try:
|
|
120
|
+
from playwright.sync_api import sync_playwright
|
|
121
|
+
|
|
122
|
+
with sync_playwright() as playwright:
|
|
123
|
+
browser_path = Path(playwright.chromium.executable_path)
|
|
124
|
+
checks.append(("Managed Chromium", browser_path.is_file(), str(browser_path)))
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
checks.append(("Managed Chromium", False, f"{type(exc).__name__}: {exc}"))
|
|
127
|
+
|
|
128
|
+
for label, passed, detail in checks:
|
|
129
|
+
print(f"[{'PASS' if passed else 'FAIL'}] {label}: {detail}")
|
|
130
|
+
if all(passed for _, passed, _ in checks):
|
|
131
|
+
print("Flow2Skill is ready to record and replay workflows.")
|
|
132
|
+
return 0
|
|
133
|
+
print("Run `python -m playwright install chromium` after fixing missing prerequisites.")
|
|
134
|
+
return 2
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main(argv: list[str] | None = None) -> int:
|
|
138
|
+
args = build_parser().parse_args(argv)
|
|
139
|
+
try:
|
|
140
|
+
if args.command == "record":
|
|
141
|
+
result = record_blocking(
|
|
142
|
+
name=args.name,
|
|
143
|
+
url=args.url,
|
|
144
|
+
output_root=args.out,
|
|
145
|
+
intent=args.intent,
|
|
146
|
+
success_criteria=args.success,
|
|
147
|
+
success_text=args.success_text,
|
|
148
|
+
redact_all_inputs=args.protect_inputs,
|
|
149
|
+
channel=args.channel,
|
|
150
|
+
)
|
|
151
|
+
print(f"Compiled: {result['output_dir']}")
|
|
152
|
+
elif args.command == "compile":
|
|
153
|
+
workflow = parse_codegen_file(
|
|
154
|
+
args.recording,
|
|
155
|
+
name=args.name,
|
|
156
|
+
intent=args.intent,
|
|
157
|
+
success_criteria=args.success,
|
|
158
|
+
success_text=args.success_text,
|
|
159
|
+
redact_all_inputs=args.protect_inputs,
|
|
160
|
+
)
|
|
161
|
+
paths = write_bundle(workflow, args.out)
|
|
162
|
+
print(json.dumps({key: str(value) for key, value in paths.items()}, indent=2))
|
|
163
|
+
elif args.command == "export":
|
|
164
|
+
paths = write_bundle(Workflow.read(args.flow), args.out)
|
|
165
|
+
print(json.dumps({key: str(value) for key, value in paths.items()}, indent=2))
|
|
166
|
+
elif args.command == "inspect":
|
|
167
|
+
print(replay(Workflow.read(args.flow), live=False))
|
|
168
|
+
elif args.command == "replay":
|
|
169
|
+
print(
|
|
170
|
+
replay(
|
|
171
|
+
Workflow.read(args.flow),
|
|
172
|
+
live=args.live,
|
|
173
|
+
headed=args.headed,
|
|
174
|
+
allow_side_effects=args.allow_side_effects,
|
|
175
|
+
channel=args.channel,
|
|
176
|
+
evidence_dir=args.evidence_dir,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
elif args.command == "demo":
|
|
180
|
+
result = compile_source(
|
|
181
|
+
demo_recording(),
|
|
182
|
+
name="Agent release gate",
|
|
183
|
+
output_dir=args.out,
|
|
184
|
+
intent="Validate a protected agent configuration and prove it is ready for replay.",
|
|
185
|
+
success_criteria="The exact deterministic ready state is visible.",
|
|
186
|
+
)
|
|
187
|
+
test_path = result["paths"]["test"]
|
|
188
|
+
print(f"Generated executable demo: {result['output_dir']}")
|
|
189
|
+
print(
|
|
190
|
+
"Verify with: F2S_LABEL_API_TOKEN_1='runtime-demo-token' "
|
|
191
|
+
"FLOW2SKILL_LIVE=1 FLOW2SKILL_ALLOW_SIDE_EFFECTS=1 "
|
|
192
|
+
f"pytest -q {test_path}"
|
|
193
|
+
)
|
|
194
|
+
elif args.command == "doctor":
|
|
195
|
+
return doctor()
|
|
196
|
+
elif args.command == "studio":
|
|
197
|
+
from .server import serve
|
|
198
|
+
|
|
199
|
+
serve(
|
|
200
|
+
host=args.host,
|
|
201
|
+
port=args.port,
|
|
202
|
+
workspace_root=args.workspace_root,
|
|
203
|
+
open_browser=not args.no_open,
|
|
204
|
+
)
|
|
205
|
+
return 0
|
|
206
|
+
except (FlowValidationError, OSError, ValueError) as exc:
|
|
207
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
208
|
+
return 2
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
if __name__ == "__main__":
|
|
212
|
+
raise SystemExit(main())
|
flow2skill/exporter.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .model import Action, FlowValidationError, Selector, Workflow
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def selector_expression(selector: Selector) -> str:
|
|
12
|
+
if selector.engine == "page":
|
|
13
|
+
expression = "page"
|
|
14
|
+
elif selector.engine == "role":
|
|
15
|
+
args = [python_value(selector.role or "")]
|
|
16
|
+
if selector.name is not None:
|
|
17
|
+
args.append(f"name={python_value(selector.name)}")
|
|
18
|
+
if selector.exact is not None:
|
|
19
|
+
args.append(f"exact={selector.exact!r}")
|
|
20
|
+
expression = f"page.get_by_role({', '.join(args)})"
|
|
21
|
+
else:
|
|
22
|
+
method = {
|
|
23
|
+
"label": "get_by_label",
|
|
24
|
+
"placeholder": "get_by_placeholder",
|
|
25
|
+
"text": "get_by_text",
|
|
26
|
+
"test_id": "get_by_test_id",
|
|
27
|
+
"title": "get_by_title",
|
|
28
|
+
"alt_text": "get_by_alt_text",
|
|
29
|
+
"css": "locator",
|
|
30
|
+
}.get(selector.engine)
|
|
31
|
+
if not method:
|
|
32
|
+
raise ValueError(f"Unsupported selector engine: {selector.engine}")
|
|
33
|
+
args = [python_value(selector.value or "")]
|
|
34
|
+
if selector.exact is not None and method != "locator":
|
|
35
|
+
args.append(f"exact={selector.exact!r}")
|
|
36
|
+
expression = f"page.{method}({', '.join(args)})"
|
|
37
|
+
for modifier in selector.modifiers:
|
|
38
|
+
if modifier == "first":
|
|
39
|
+
expression += ".first"
|
|
40
|
+
elif modifier.startswith("nth:"):
|
|
41
|
+
expression += f".nth({int(modifier.split(':', 1)[1])})"
|
|
42
|
+
else:
|
|
43
|
+
raise FlowValidationError(f"Unsupported selector modifier: {modifier}")
|
|
44
|
+
return expression
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def python_value(value: Any) -> str:
|
|
48
|
+
if isinstance(value, str) and "${" in value:
|
|
49
|
+
return f"resolve_template({value!r})"
|
|
50
|
+
return repr(value)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def action_python(action: Action, *, indent: str = " ") -> list[str]:
|
|
54
|
+
target = selector_expression(action.selector)
|
|
55
|
+
if action.kind == "goto":
|
|
56
|
+
return [f"{indent}page.goto({python_value(action.value)})"]
|
|
57
|
+
if action.kind in {"click", "check", "uncheck", "hover"}:
|
|
58
|
+
return [f"{indent}{target}.{action.kind}()"]
|
|
59
|
+
if action.kind in {"fill", "press", "select_option"}:
|
|
60
|
+
return [f"{indent}{target}.{action.kind}({python_value(action.value)})"]
|
|
61
|
+
if action.kind == "assert_visible":
|
|
62
|
+
return [f"{indent}expect({target}).to_be_visible()"]
|
|
63
|
+
if action.kind == "assert_text":
|
|
64
|
+
return [f"{indent}expect({target}).to_contain_text({python_value(action.expected)})"]
|
|
65
|
+
if action.kind == "assert_exact_text":
|
|
66
|
+
return [f"{indent}expect({target}).to_have_text({python_value(action.expected)})"]
|
|
67
|
+
if action.kind == "assert_url":
|
|
68
|
+
return [f"{indent}expect(page).to_have_url({python_value(action.expected)})"]
|
|
69
|
+
if action.kind == "assert_value":
|
|
70
|
+
return [f"{indent}expect({target}).to_have_value({python_value(action.expected)})"]
|
|
71
|
+
raise ValueError(f"Unsupported action: {action.kind}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def render_test(workflow: Workflow) -> str:
|
|
75
|
+
workflow.validate()
|
|
76
|
+
risky = [a for a in workflow.actions if a.risk != "safe"]
|
|
77
|
+
body: list[str] = []
|
|
78
|
+
for index, action in enumerate(workflow.actions, start=1):
|
|
79
|
+
summary = describe_action(action).replace("\r", "\\r").replace("\n", "\\n")
|
|
80
|
+
body.append(f" # Step {index}: {summary}")
|
|
81
|
+
body.extend(action_python(action, indent=" "))
|
|
82
|
+
if not any(a.kind.startswith("assert_") for a in workflow.actions):
|
|
83
|
+
body.append(' pytest.fail("No executable assertion was captured")')
|
|
84
|
+
|
|
85
|
+
risk_guard = ""
|
|
86
|
+
if risky:
|
|
87
|
+
labels = ", ".join(describe_action(action) for action in risky)
|
|
88
|
+
message = f"Reviewed or approval-gated actions present: {labels}"
|
|
89
|
+
risk_guard = f"""\n if os.getenv("FLOW2SKILL_ALLOW_SIDE_EFFECTS") != "1":\n pytest.fail({message!r})\n"""
|
|
90
|
+
|
|
91
|
+
module_doc = (
|
|
92
|
+
f"Generated by Flow2Skill from `{workflow.name}`.\n\n"
|
|
93
|
+
"Live browser execution is opt-in. Protected values are resolved only at runtime."
|
|
94
|
+
)
|
|
95
|
+
generated = f"""{module_doc!r}
|
|
96
|
+
from __future__ import annotations
|
|
97
|
+
|
|
98
|
+
import os
|
|
99
|
+
import re
|
|
100
|
+
|
|
101
|
+
import pytest
|
|
102
|
+
from playwright.sync_api import expect, sync_playwright
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def require_env(name: str) -> str:
|
|
106
|
+
value = os.getenv(name)
|
|
107
|
+
if value is None:
|
|
108
|
+
pytest.fail(f"Required Flow2Skill variable is missing: {{name}}")
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def resolve_template(value: str) -> str:
|
|
113
|
+
return re.sub(
|
|
114
|
+
r"\\$\\{{(F2S_[A-Z0-9_]+)\\}}",
|
|
115
|
+
lambda match: require_env(match.group(1)),
|
|
116
|
+
value,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.skipif(
|
|
121
|
+
os.getenv("FLOW2SKILL_LIVE") != "1",
|
|
122
|
+
reason="Set FLOW2SKILL_LIVE=1 to run this recorded browser regression.",
|
|
123
|
+
)
|
|
124
|
+
def test_{workflow.slug.replace("-", "_")}() -> None:{risk_guard}
|
|
125
|
+
with sync_playwright() as playwright:
|
|
126
|
+
launch_options = {{"headless": os.getenv("FLOW2SKILL_HEADED") != "1"}}
|
|
127
|
+
if channel := os.getenv("FLOW2SKILL_CHANNEL"):
|
|
128
|
+
launch_options["channel"] = channel
|
|
129
|
+
browser = playwright.chromium.launch(**launch_options)
|
|
130
|
+
context = browser.new_context()
|
|
131
|
+
page = context.new_page()
|
|
132
|
+
try:
|
|
133
|
+
{chr(10).join(body)}
|
|
134
|
+
finally:
|
|
135
|
+
context.close()
|
|
136
|
+
browser.close()
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
ast.parse(generated)
|
|
140
|
+
except SyntaxError as exc:
|
|
141
|
+
raise FlowValidationError(f"Generated test is invalid Python: {exc.msg}") from exc
|
|
142
|
+
return generated
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def describe_action(action: Action) -> str:
|
|
146
|
+
target = action.selector.label()
|
|
147
|
+
if action.kind == "goto":
|
|
148
|
+
return f"navigate to {action.value}"
|
|
149
|
+
if action.kind == "click":
|
|
150
|
+
return f"click {target}"
|
|
151
|
+
if action.kind == "fill":
|
|
152
|
+
rendered = (
|
|
153
|
+
"a protected environment value"
|
|
154
|
+
if str(action.value).startswith("${")
|
|
155
|
+
else repr(action.value)
|
|
156
|
+
)
|
|
157
|
+
return f"fill {target} with {rendered}"
|
|
158
|
+
if action.kind == "press":
|
|
159
|
+
return f"press {action.value!r} in {target}"
|
|
160
|
+
if action.kind == "select_option":
|
|
161
|
+
return f"select {action.value!r} in {target}"
|
|
162
|
+
if action.kind in {"check", "uncheck", "hover"}:
|
|
163
|
+
return f"{action.kind.replace('_', ' ')} {target}"
|
|
164
|
+
if action.kind == "assert_visible":
|
|
165
|
+
return f"verify {target} is visible"
|
|
166
|
+
if action.kind == "assert_text":
|
|
167
|
+
return f"verify {target} contains {action.expected!r}"
|
|
168
|
+
if action.kind == "assert_exact_text":
|
|
169
|
+
return f"verify {target} exactly equals {action.expected!r}"
|
|
170
|
+
if action.kind == "assert_url":
|
|
171
|
+
return f"verify the URL is {action.expected!r}"
|
|
172
|
+
if action.kind == "assert_value":
|
|
173
|
+
return f"verify {target} has value {action.expected!r}"
|
|
174
|
+
raise ValueError(f"Unsupported action: {action.kind}")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def render_skill(workflow: Workflow) -> str:
|
|
178
|
+
workflow.validate()
|
|
179
|
+
|
|
180
|
+
def risk_suffix(action: Action) -> str:
|
|
181
|
+
if action.risk == "approval":
|
|
182
|
+
return " **Approval required before this step. Exact user approval is mandatory.**"
|
|
183
|
+
if action.risk == "review":
|
|
184
|
+
return " **Review this step before replay.**"
|
|
185
|
+
return ""
|
|
186
|
+
|
|
187
|
+
steps = "\n".join(
|
|
188
|
+
f"{index}. {describe_action(action)}" + risk_suffix(action)
|
|
189
|
+
for index, action in enumerate(workflow.actions, start=1)
|
|
190
|
+
)
|
|
191
|
+
variables = (
|
|
192
|
+
"\n".join(
|
|
193
|
+
f"- `{name}`: provide at runtime; never write it into the skill."
|
|
194
|
+
for name in workflow.variables
|
|
195
|
+
)
|
|
196
|
+
if workflow.variables
|
|
197
|
+
else "- None captured."
|
|
198
|
+
)
|
|
199
|
+
warnings = (
|
|
200
|
+
"\n".join(f"- {warning}" for warning in workflow.warnings)
|
|
201
|
+
if workflow.warnings
|
|
202
|
+
else "- None."
|
|
203
|
+
)
|
|
204
|
+
description = json.dumps(workflow.intent, ensure_ascii=False)
|
|
205
|
+
return f"""---
|
|
206
|
+
name: {workflow.slug}
|
|
207
|
+
description: {description}
|
|
208
|
+
version: 1.0.0
|
|
209
|
+
metadata:
|
|
210
|
+
flow2skill:
|
|
211
|
+
fingerprint: {workflow.fingerprint()}
|
|
212
|
+
source: {json.dumps(workflow.source, ensure_ascii=False)}
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
# {workflow.name}
|
|
216
|
+
|
|
217
|
+
## Trigger
|
|
218
|
+
|
|
219
|
+
Use this skill when the user wants to: {workflow.intent.rstrip(".")}.
|
|
220
|
+
|
|
221
|
+
## Safety boundary
|
|
222
|
+
|
|
223
|
+
- Treat this workflow as a demonstrated procedure, not permission to perform external side effects.
|
|
224
|
+
- Posting, publishing, purchasing, sending, submitting, account changes, and destructive actions require exact user approval at the marked step.
|
|
225
|
+
- Run the generated pytest in headless mode first. Use headed mode only when visual inspection is needed.
|
|
226
|
+
- Never place secrets in this file. Supply protected values through environment variables.
|
|
227
|
+
|
|
228
|
+
## Runtime variables
|
|
229
|
+
|
|
230
|
+
{variables}
|
|
231
|
+
|
|
232
|
+
## Procedure
|
|
233
|
+
|
|
234
|
+
{steps}
|
|
235
|
+
|
|
236
|
+
## Success gate
|
|
237
|
+
|
|
238
|
+
{workflow.success_criteria}
|
|
239
|
+
|
|
240
|
+
The procedure is complete only when all recorded assertions pass. A browser action completing without an assertion is execution evidence, not proof of the intended outcome.
|
|
241
|
+
|
|
242
|
+
## Verification
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
FLOW2SKILL_LIVE=1 pytest -q test_{workflow.slug.replace("-", "_")}.py
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Set `FLOW2SKILL_HEADED=1` for a visible browser. Set `FLOW2SKILL_ALLOW_SIDE_EFFECTS=1` only after reviewing every marked step and obtaining exact approval for approval-gated actions.
|
|
249
|
+
|
|
250
|
+
## Capture warnings
|
|
251
|
+
|
|
252
|
+
{warnings}
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _yaml_scalar(value: Any) -> str:
|
|
257
|
+
if value is None:
|
|
258
|
+
return "null"
|
|
259
|
+
if value is True:
|
|
260
|
+
return "true"
|
|
261
|
+
if value is False:
|
|
262
|
+
return "false"
|
|
263
|
+
if isinstance(value, (int, float)):
|
|
264
|
+
return str(value)
|
|
265
|
+
return json.dumps(str(value), ensure_ascii=False)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _yaml_lines(value: Any, indent: int = 0) -> list[str]:
|
|
269
|
+
pad = " " * indent
|
|
270
|
+
if isinstance(value, dict):
|
|
271
|
+
lines: list[str] = []
|
|
272
|
+
for key, item in value.items():
|
|
273
|
+
if isinstance(item, (dict, list)):
|
|
274
|
+
lines.append(f"{pad}{key}:")
|
|
275
|
+
lines.extend(_yaml_lines(item, indent + 2))
|
|
276
|
+
else:
|
|
277
|
+
lines.append(f"{pad}{key}: {_yaml_scalar(item)}")
|
|
278
|
+
return lines
|
|
279
|
+
if isinstance(value, list):
|
|
280
|
+
lines = []
|
|
281
|
+
for item in value:
|
|
282
|
+
if isinstance(item, dict):
|
|
283
|
+
first, *rest = _yaml_lines(item, indent + 2)
|
|
284
|
+
lines.append(f"{pad}- {first.strip()}")
|
|
285
|
+
lines.extend(rest)
|
|
286
|
+
elif isinstance(item, list):
|
|
287
|
+
lines.append(f"{pad}-")
|
|
288
|
+
lines.extend(_yaml_lines(item, indent + 2))
|
|
289
|
+
else:
|
|
290
|
+
lines.append(f"{pad}- {_yaml_scalar(item)}")
|
|
291
|
+
return lines
|
|
292
|
+
return [f"{pad}{_yaml_scalar(value)}"]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def render_readme(workflow: Workflow) -> str:
|
|
296
|
+
risky = sum(action.risk == "approval" for action in workflow.actions)
|
|
297
|
+
assertions = sum(action.kind.startswith("assert_") for action in workflow.actions)
|
|
298
|
+
return f"""# {workflow.name}
|
|
299
|
+
|
|
300
|
+
Generated locally by Flow2Skill from a successful human-demonstrated browser workflow.
|
|
301
|
+
|
|
302
|
+
- Steps: {len(workflow.actions)}
|
|
303
|
+
- Assertions: {assertions}
|
|
304
|
+
- Approval-gated steps: {risky}
|
|
305
|
+
- Fingerprint: `{workflow.fingerprint()}`
|
|
306
|
+
|
|
307
|
+
## Files
|
|
308
|
+
|
|
309
|
+
- `flow.json`: canonical machine-readable workflow
|
|
310
|
+
- `flow.yaml`: human-readable workflow
|
|
311
|
+
- `SKILL.md`: portable agent procedure
|
|
312
|
+
- `test_{workflow.slug.replace("-", "_")}.py`: standalone Playwright/pytest regression
|
|
313
|
+
|
|
314
|
+
## Verify
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
python -m pip install playwright pytest
|
|
318
|
+
FLOW2SKILL_LIVE=1 pytest -q test_{workflow.slug.replace("-", "_")}.py
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Use `FLOW2SKILL_HEADED=1` to watch the replay. Protected input variables are listed in `SKILL.md` and must be supplied through the environment.
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def write_bundle(workflow: Workflow, output_dir: str | Path) -> dict[str, Path]:
|
|
326
|
+
workflow.validate()
|
|
327
|
+
root = Path(output_dir).resolve()
|
|
328
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
329
|
+
previous_test: Path | None = None
|
|
330
|
+
manifest_path = root / "flow.json"
|
|
331
|
+
if manifest_path.is_file():
|
|
332
|
+
try:
|
|
333
|
+
previous_workflow = Workflow.read(manifest_path)
|
|
334
|
+
except (OSError, ValueError):
|
|
335
|
+
pass
|
|
336
|
+
else:
|
|
337
|
+
previous_test = root / f"test_{previous_workflow.slug.replace('-', '_')}.py"
|
|
338
|
+
payload = workflow.to_dict()
|
|
339
|
+
paths = {
|
|
340
|
+
"flow_json": root / "flow.json",
|
|
341
|
+
"flow_yaml": root / "flow.yaml",
|
|
342
|
+
"skill": root / "SKILL.md",
|
|
343
|
+
"test": root / f"test_{workflow.slug.replace('-', '_')}.py",
|
|
344
|
+
"readme": root / "README.md",
|
|
345
|
+
}
|
|
346
|
+
if (
|
|
347
|
+
previous_test is not None
|
|
348
|
+
and previous_test != paths["test"]
|
|
349
|
+
and (previous_test.is_file() or previous_test.is_symlink())
|
|
350
|
+
):
|
|
351
|
+
previous_test.unlink()
|
|
352
|
+
paths["flow_json"].write_text(
|
|
353
|
+
json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
354
|
+
)
|
|
355
|
+
paths["flow_yaml"].write_text("\n".join(_yaml_lines(payload)) + "\n", encoding="utf-8")
|
|
356
|
+
paths["skill"].write_text(render_skill(workflow), encoding="utf-8")
|
|
357
|
+
paths["test"].write_text(render_test(workflow), encoding="utf-8")
|
|
358
|
+
paths["readme"].write_text(render_readme(workflow), encoding="utf-8")
|
|
359
|
+
return paths
|