sel2pw 0.1.1__tar.gz

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 (37) hide show
  1. sel2pw-0.1.1/LICENSE +21 -0
  2. sel2pw-0.1.1/PKG-INFO +181 -0
  3. sel2pw-0.1.1/README.md +156 -0
  4. sel2pw-0.1.1/pyproject.toml +34 -0
  5. sel2pw-0.1.1/setup.cfg +4 -0
  6. sel2pw-0.1.1/src/sel2pw/__init__.py +3 -0
  7. sel2pw-0.1.1/src/sel2pw/agent.py +230 -0
  8. sel2pw-0.1.1/src/sel2pw/cli.py +187 -0
  9. sel2pw-0.1.1/src/sel2pw/gates.py +289 -0
  10. sel2pw-0.1.1/src/sel2pw/inventory.py +442 -0
  11. sel2pw-0.1.1/src/sel2pw/ledger.py +75 -0
  12. sel2pw-0.1.1/src/sel2pw/llm/__init__.py +102 -0
  13. sel2pw-0.1.1/src/sel2pw/llm/anthropic_provider.py +160 -0
  14. sel2pw-0.1.1/src/sel2pw/llm/base.py +85 -0
  15. sel2pw-0.1.1/src/sel2pw/llm/cache.py +130 -0
  16. sel2pw-0.1.1/src/sel2pw/llm/openai_provider.py +138 -0
  17. sel2pw-0.1.1/src/sel2pw/manifest.py +93 -0
  18. sel2pw-0.1.1/src/sel2pw/mcp_server.py +258 -0
  19. sel2pw-0.1.1/src/sel2pw/prompts/__init__.py +63 -0
  20. sel2pw-0.1.1/src/sel2pw/prompts/system_prompt.md +76 -0
  21. sel2pw-0.1.1/src/sel2pw/reports.py +164 -0
  22. sel2pw-0.1.1/src/sel2pw/tools.py +325 -0
  23. sel2pw-0.1.1/src/sel2pw/validate.py +124 -0
  24. sel2pw-0.1.1/src/sel2pw/workspace.py +192 -0
  25. sel2pw-0.1.1/src/sel2pw.egg-info/PKG-INFO +181 -0
  26. sel2pw-0.1.1/src/sel2pw.egg-info/SOURCES.txt +35 -0
  27. sel2pw-0.1.1/src/sel2pw.egg-info/dependency_links.txt +1 -0
  28. sel2pw-0.1.1/src/sel2pw.egg-info/entry_points.txt +3 -0
  29. sel2pw-0.1.1/src/sel2pw.egg-info/requires.txt +20 -0
  30. sel2pw-0.1.1/src/sel2pw.egg-info/top_level.txt +1 -0
  31. sel2pw-0.1.1/tests/test_agent.py +121 -0
  32. sel2pw-0.1.1/tests/test_cache.py +65 -0
  33. sel2pw-0.1.1/tests/test_gates_and_reports.py +106 -0
  34. sel2pw-0.1.1/tests/test_inventory.py +60 -0
  35. sel2pw-0.1.1/tests/test_mcp_server.py +72 -0
  36. sel2pw-0.1.1/tests/test_providers.py +168 -0
  37. sel2pw-0.1.1/tests/test_workspace_and_tools.py +80 -0
sel2pw-0.1.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shan Konduru
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
sel2pw-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.4
2
+ Name: sel2pw
3
+ Version: 0.1.1
4
+ Summary: Selenium Java -> Python Playwright migration agent (CLI, MCP server, LLM-agnostic agent)
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Provides-Extra: anthropic
10
+ Requires-Dist: anthropic>=1.0; extra == "anthropic"
11
+ Provides-Extra: openai
12
+ Requires-Dist: openai>=1.40; extra == "openai"
13
+ Provides-Extra: mcp
14
+ Requires-Dist: mcp>=2.0; extra == "mcp"
15
+ Provides-Extra: all
16
+ Requires-Dist: anthropic>=1.0; extra == "all"
17
+ Requires-Dist: openai>=1.40; extra == "all"
18
+ Requires-Dist: mcp>=2.0; extra == "all"
19
+ Provides-Extra: dev
20
+ Requires-Dist: anthropic>=1.0; extra == "dev"
21
+ Requires-Dist: openai>=1.40; extra == "dev"
22
+ Requires-Dist: mcp>=2.0; extra == "dev"
23
+ Requires-Dist: pytest>=8; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # sel2pw — Selenium Java → Python Playwright migration agent
27
+
28
+ [![Tests](https://github.com/ShanKonduru/selenium2playwright/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/ShanKonduru/selenium2playwright/actions/workflows/tests.yml)
29
+
30
+ Implements the agent described in
31
+ [`selenium_java_to_playwright_migration_agent_system_prompt.md`](selenium_java_to_playwright_migration_agent_system_prompt.md)
32
+ three ways, all on one shared core:
33
+
34
+ | Surface | Command | Who does the reasoning |
35
+ |---|---|---|
36
+ | CLI | `sel2pw inventory / migrate / validate` | Built-in agent + any LLM provider |
37
+ | MCP server | `sel2pw mcp` (or `sel2pw-mcp`) | The MCP host's LLM (Claude Code/Desktop, Copilot, Cursor…), or the built-in agent via `run_migration_agent` |
38
+ | Library | `MigrationAgent(get_provider(...), Workspace.create(...)).run()` | Any `LLMProvider` |
39
+
40
+ ## Architecture
41
+
42
+ ```
43
+ src/sel2pw/
44
+ workspace.py sandbox: named roots (source=ro, target=rw, --reference=ro), path-escape checks, secret redaction
45
+ inventory.py deterministic Java scan: tests (IDs "Class#method"), lifecycle hooks, page objects,
46
+ dependencies, locators, data-provider rows, constructs to flag (waits, frames, alerts, JS, ...)
47
+ validate.py compile (in-process), pytest --collect-only, pytest run (opt-in only)
48
+ ledger.py structured mappings + issues recorded by the agent
49
+ gates.py deterministic acceptance gates
50
+ reports.py code-generated migration_map.md / migration_validation.md
51
+ manifest.py run fingerprint + manifest under <target>/.sel2pw/runs/
52
+ tools.py ONE tool registry + input validation, used by both the agent and the MCP server
53
+ agent.py provider-agnostic loop (append-only history, gate-driven repair rounds, finalize)
54
+ llm/ base.py (neutral messages) + anthropic/openai adapters + registry + cache.py (record/replay)
55
+ prompts/ system_prompt.md (extracted from the spec) + static TOOL LAYER appendix
56
+ cli.py, mcp_server.py
57
+ ```
58
+
59
+ The spec says *"prompt text alone is not a security boundary"*, so the tool layer enforces it:
60
+ the source root is never writable; no path can leave its root; secrets are redacted before reaching any LLM;
61
+ overwriting a target file that existed before the run is recorded and reported; browser execution
62
+ (`run_check kind=run`) is refused unless the operator passes `--allow-browser-tests`.
63
+ There is no generic shell tool.
64
+
65
+ ## Install
66
+
67
+ ```bash
68
+ pip install -e ".[all]" # or .[anthropic] / .[openai] / .[mcp]; .[dev] adds pytest
69
+ ```
70
+
71
+ ## CLI
72
+
73
+ ```bash
74
+ # Deterministic inventory (no LLM, free)
75
+ sel2pw inventory path/to/selenium-project --out inventory.md
76
+
77
+ # Full migration with the built-in agent
78
+ sel2pw migrate --source path/to/selenium-project --target path/to/innoharmony-repo \
79
+ --task "Migrate LoginTest and its page objects" --scope "LoginTest#*"
80
+
81
+ # Static checks on migrated code (add --collect for pytest --collect-only)
82
+ sel2pw validate path/to/innoharmony-repo --collect --python path/to/repo/.venv/Scripts/python
83
+ ```
84
+
85
+ Useful `migrate` flags: `--reference innoharmony=path/to/framework` (extra read-only root),
86
+ `--allow-browser-tests` (approved non-prod env only), `--python` (target repo's interpreter for pytest),
87
+ `--scope`, `--max-repairs`, `--gate-collect`, `--report-dir`, `--cache`, `--max-turns`, `--system-prompt FILE`.
88
+ Exit code is 0 only when the acceptance gates pass.
89
+
90
+ ## Determinism
91
+
92
+ LLM output can't be made bit-for-bit deterministic. Claude Opus 5.5 rejects `temperature`, and OpenAI's `seed`
93
+ is only best effort. sel2pw makes every *outcome* deterministic and every *run* reproducible:
94
+
95
+ | Layer | Mechanism |
96
+ |---|---|
97
+ | **What counts as "done"** | Deterministic acceptance gates: `scope_coverage` (every in-scope test mapped), `mapping_integrity` (target file + symbol exist, blocked/excluded have reasons), `assertion_parity`, `parametrize_parity` (data-provider rows = `parametrize` cases), `disabled_preserved` (stays skipped), `compile`, `lint` (no unjustified sleeps/`networkidle`, no secrets or redacted placeholders), optional `collect`. Failures are sent back to the model for up to `--max-repairs` rounds, and the run fails if they persist. |
98
+ | **Reports** | The agent records structured data (`record_mapping`, `record_issue`). It cannot write `migration_map.md` or `migration_validation.md`. Code renders both, with no timestamps, so identical inputs give byte-identical reports. Text the model wrote is shown in labelled "agent" columns or sections. |
99
+ | **Reproducing a run** | Record/replay cache (`--cache auto` by default) in `<target>/.sel2pw/cache`. It is keyed by a SHA-256 of the provider settings, system prompt, tool schemas and the whole conversation, including tool results. Re-running with the same inputs and the same starting target replays the run exactly without calling the model. `--cache replay` makes any deviation an error, which suits CI. `--cache off` forces a fresh attempt. Tool output is normalized (sorted listings, pytest timings stripped) so keys stay stable. |
100
+ | **Traceability** | Every run writes `<target>/.sel2pw/runs/<run_id>/`: `manifest.json` (model + settings, prompt/tools/task/source-tree/inventory hashes, target tree hash before/after, cache hits, usage, SDK versions), `gates.json`, `ledger.json`, `events.jsonl`. The stable part of the fingerprint is also in the validation report. |
101
+
102
+ Escape hatches the gates recognise (all appear in reports for reviewers): a `justification` on a mapping
103
+ (intentional assertion, case or skip difference), and line comments `# sel2pw: allow-sleep: <reason>`,
104
+ `# sel2pw: allow-networkidle: <reason>`, `# sel2pw: allow-secret` (test data, not credentials).
105
+
106
+ Add `.sel2pw/` to the target's `.gitignore`, or commit `.sel2pw/cache` if you want others to be able to replay your runs.
107
+
108
+ ### LLM providers (`sel2pw providers`)
109
+
110
+ | `--provider` | Notes |
111
+ |---|---|
112
+ | `anthropic` (default) | Default model `claude-opus-5-5`. Auth: `ANTHROPIC_API_KEY` or `ant auth login`. |
113
+ | `openai` | `OPENAI_API_KEY`, `--model` required |
114
+ | `azure-openai` | `AZURE_OPENAI_BASE_URL` (v1 endpoint) + `AZURE_OPENAI_API_KEY`; `--model` = deployment |
115
+ | `gemini` | `GEMINI_API_KEY`, OpenAI-compatible endpoint, `--model` required |
116
+ | `ollama` | Local, `OLLAMA_BASE_URL` (default `http://localhost:11434/v1`); use a tool-capable model |
117
+ | `openai-compatible` | vLLM, LM Studio, OpenRouter, LiteLLM… via `SEL2PW_BASE_URL` / `SEL2PW_API_KEY` |
118
+ | `my_pkg.module:MyProvider` | Plugin: any `sel2pw.llm.base.LLMProvider` subclass |
119
+
120
+ Defaults can come from env: `SEL2PW_PROVIDER`, `SEL2PW_MODEL`, `SEL2PW_EFFORT`.
121
+
122
+ Anthropic adapter specifics: streaming with `eager_input_streaming` tools (whole-file writes stream as they are
123
+ generated; inputs are re-validated before running), adaptive thinking with `--effort` (default `high`),
124
+ automatic prompt caching, and **server-side refusal fallbacks enabled by default** (`fallbacks="default"`).
125
+ Pass `--no-fallbacks` to turn them off; you must do that on Bedrock/Vertex/Foundry gateways, where the parameter is rejected.
126
+
127
+ ### Adding a provider
128
+
129
+ ```python
130
+ from sel2pw.llm.base import LLMProvider, AssistantTurn, ToolCall
131
+
132
+ class MyProvider(LLMProvider):
133
+ name = "mine"
134
+ def __init__(self, model=None, **kw):
135
+ super().__init__(model or "my-model")
136
+ def complete(self, system, messages, tools) -> AssistantTurn:
137
+ ... # convert UserMessage / AssistantTurn(raw) / ToolResultsMessage to your API, call it,
138
+ # return AssistantTurn(text, [ToolCall(id, name, args)], stop_reason, raw=<native turn>)
139
+ ```
140
+
141
+ ```bash
142
+ sel2pw migrate --provider my_pkg.providers:MyProvider ...
143
+ ```
144
+
145
+ ## MCP server
146
+
147
+ Tools: `java_inventory`, `list_files`, `read_file`, `search`, `write_file`, `edit_file`, `run_check`,
148
+ `record_mapping`, `record_issue`, `check_acceptance`, `finalize_migration` (gates + generated reports + run manifest),
149
+ `run_migration_agent` (hide with `--no-agent-tool`). Prompt: `migrate_selenium_to_playwright`.
150
+ Resource: `sel2pw://system-prompt`. The roots, `--scope` and gate options are fixed at server start, so a host
151
+ can't widen the scope. Host-driven runs get the same gates and generated reports through `finalize_migration`.
152
+
153
+ **Claude Code**
154
+
155
+ ```bash
156
+ claude mcp add sel2pw -- sel2pw mcp --source C:/work/selenium-tests --target C:/work/innoharmony-tests
157
+ ```
158
+
159
+ **VS Code / Cursor / Claude Desktop** (`mcp.json` / `claude_desktop_config.json`)
160
+
161
+ ```json
162
+ {
163
+ "mcpServers": {
164
+ "sel2pw": {
165
+ "command": "C:/myprojects/selenium2playwright/.venv/Scripts/sel2pw.exe",
166
+ "args": ["mcp", "--source", "C:/work/selenium-tests", "--target", "C:/work/innoharmony-tests"]
167
+ }
168
+ }
169
+ }
170
+ ```
171
+
172
+ HTTP transport: `sel2pw mcp ... --transport streamable-http`.
173
+
174
+ ## Tests
175
+
176
+ ```bash
177
+ pytest -q
178
+ ```
179
+
180
+ No test calls a real LLM. The provider adapters are tested against fake clients, and the MCP tests run the
181
+ server in-process.
sel2pw-0.1.1/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # sel2pw — Selenium Java → Python Playwright migration agent
2
+
3
+ [![Tests](https://github.com/ShanKonduru/selenium2playwright/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/ShanKonduru/selenium2playwright/actions/workflows/tests.yml)
4
+
5
+ Implements the agent described in
6
+ [`selenium_java_to_playwright_migration_agent_system_prompt.md`](selenium_java_to_playwright_migration_agent_system_prompt.md)
7
+ three ways, all on one shared core:
8
+
9
+ | Surface | Command | Who does the reasoning |
10
+ |---|---|---|
11
+ | CLI | `sel2pw inventory / migrate / validate` | Built-in agent + any LLM provider |
12
+ | MCP server | `sel2pw mcp` (or `sel2pw-mcp`) | The MCP host's LLM (Claude Code/Desktop, Copilot, Cursor…), or the built-in agent via `run_migration_agent` |
13
+ | Library | `MigrationAgent(get_provider(...), Workspace.create(...)).run()` | Any `LLMProvider` |
14
+
15
+ ## Architecture
16
+
17
+ ```
18
+ src/sel2pw/
19
+ workspace.py sandbox: named roots (source=ro, target=rw, --reference=ro), path-escape checks, secret redaction
20
+ inventory.py deterministic Java scan: tests (IDs "Class#method"), lifecycle hooks, page objects,
21
+ dependencies, locators, data-provider rows, constructs to flag (waits, frames, alerts, JS, ...)
22
+ validate.py compile (in-process), pytest --collect-only, pytest run (opt-in only)
23
+ ledger.py structured mappings + issues recorded by the agent
24
+ gates.py deterministic acceptance gates
25
+ reports.py code-generated migration_map.md / migration_validation.md
26
+ manifest.py run fingerprint + manifest under <target>/.sel2pw/runs/
27
+ tools.py ONE tool registry + input validation, used by both the agent and the MCP server
28
+ agent.py provider-agnostic loop (append-only history, gate-driven repair rounds, finalize)
29
+ llm/ base.py (neutral messages) + anthropic/openai adapters + registry + cache.py (record/replay)
30
+ prompts/ system_prompt.md (extracted from the spec) + static TOOL LAYER appendix
31
+ cli.py, mcp_server.py
32
+ ```
33
+
34
+ The spec says *"prompt text alone is not a security boundary"*, so the tool layer enforces it:
35
+ the source root is never writable; no path can leave its root; secrets are redacted before reaching any LLM;
36
+ overwriting a target file that existed before the run is recorded and reported; browser execution
37
+ (`run_check kind=run`) is refused unless the operator passes `--allow-browser-tests`.
38
+ There is no generic shell tool.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install -e ".[all]" # or .[anthropic] / .[openai] / .[mcp]; .[dev] adds pytest
44
+ ```
45
+
46
+ ## CLI
47
+
48
+ ```bash
49
+ # Deterministic inventory (no LLM, free)
50
+ sel2pw inventory path/to/selenium-project --out inventory.md
51
+
52
+ # Full migration with the built-in agent
53
+ sel2pw migrate --source path/to/selenium-project --target path/to/innoharmony-repo \
54
+ --task "Migrate LoginTest and its page objects" --scope "LoginTest#*"
55
+
56
+ # Static checks on migrated code (add --collect for pytest --collect-only)
57
+ sel2pw validate path/to/innoharmony-repo --collect --python path/to/repo/.venv/Scripts/python
58
+ ```
59
+
60
+ Useful `migrate` flags: `--reference innoharmony=path/to/framework` (extra read-only root),
61
+ `--allow-browser-tests` (approved non-prod env only), `--python` (target repo's interpreter for pytest),
62
+ `--scope`, `--max-repairs`, `--gate-collect`, `--report-dir`, `--cache`, `--max-turns`, `--system-prompt FILE`.
63
+ Exit code is 0 only when the acceptance gates pass.
64
+
65
+ ## Determinism
66
+
67
+ LLM output can't be made bit-for-bit deterministic. Claude Opus 5.5 rejects `temperature`, and OpenAI's `seed`
68
+ is only best effort. sel2pw makes every *outcome* deterministic and every *run* reproducible:
69
+
70
+ | Layer | Mechanism |
71
+ |---|---|
72
+ | **What counts as "done"** | Deterministic acceptance gates: `scope_coverage` (every in-scope test mapped), `mapping_integrity` (target file + symbol exist, blocked/excluded have reasons), `assertion_parity`, `parametrize_parity` (data-provider rows = `parametrize` cases), `disabled_preserved` (stays skipped), `compile`, `lint` (no unjustified sleeps/`networkidle`, no secrets or redacted placeholders), optional `collect`. Failures are sent back to the model for up to `--max-repairs` rounds, and the run fails if they persist. |
73
+ | **Reports** | The agent records structured data (`record_mapping`, `record_issue`). It cannot write `migration_map.md` or `migration_validation.md`. Code renders both, with no timestamps, so identical inputs give byte-identical reports. Text the model wrote is shown in labelled "agent" columns or sections. |
74
+ | **Reproducing a run** | Record/replay cache (`--cache auto` by default) in `<target>/.sel2pw/cache`. It is keyed by a SHA-256 of the provider settings, system prompt, tool schemas and the whole conversation, including tool results. Re-running with the same inputs and the same starting target replays the run exactly without calling the model. `--cache replay` makes any deviation an error, which suits CI. `--cache off` forces a fresh attempt. Tool output is normalized (sorted listings, pytest timings stripped) so keys stay stable. |
75
+ | **Traceability** | Every run writes `<target>/.sel2pw/runs/<run_id>/`: `manifest.json` (model + settings, prompt/tools/task/source-tree/inventory hashes, target tree hash before/after, cache hits, usage, SDK versions), `gates.json`, `ledger.json`, `events.jsonl`. The stable part of the fingerprint is also in the validation report. |
76
+
77
+ Escape hatches the gates recognise (all appear in reports for reviewers): a `justification` on a mapping
78
+ (intentional assertion, case or skip difference), and line comments `# sel2pw: allow-sleep: <reason>`,
79
+ `# sel2pw: allow-networkidle: <reason>`, `# sel2pw: allow-secret` (test data, not credentials).
80
+
81
+ Add `.sel2pw/` to the target's `.gitignore`, or commit `.sel2pw/cache` if you want others to be able to replay your runs.
82
+
83
+ ### LLM providers (`sel2pw providers`)
84
+
85
+ | `--provider` | Notes |
86
+ |---|---|
87
+ | `anthropic` (default) | Default model `claude-opus-5-5`. Auth: `ANTHROPIC_API_KEY` or `ant auth login`. |
88
+ | `openai` | `OPENAI_API_KEY`, `--model` required |
89
+ | `azure-openai` | `AZURE_OPENAI_BASE_URL` (v1 endpoint) + `AZURE_OPENAI_API_KEY`; `--model` = deployment |
90
+ | `gemini` | `GEMINI_API_KEY`, OpenAI-compatible endpoint, `--model` required |
91
+ | `ollama` | Local, `OLLAMA_BASE_URL` (default `http://localhost:11434/v1`); use a tool-capable model |
92
+ | `openai-compatible` | vLLM, LM Studio, OpenRouter, LiteLLM… via `SEL2PW_BASE_URL` / `SEL2PW_API_KEY` |
93
+ | `my_pkg.module:MyProvider` | Plugin: any `sel2pw.llm.base.LLMProvider` subclass |
94
+
95
+ Defaults can come from env: `SEL2PW_PROVIDER`, `SEL2PW_MODEL`, `SEL2PW_EFFORT`.
96
+
97
+ Anthropic adapter specifics: streaming with `eager_input_streaming` tools (whole-file writes stream as they are
98
+ generated; inputs are re-validated before running), adaptive thinking with `--effort` (default `high`),
99
+ automatic prompt caching, and **server-side refusal fallbacks enabled by default** (`fallbacks="default"`).
100
+ Pass `--no-fallbacks` to turn them off; you must do that on Bedrock/Vertex/Foundry gateways, where the parameter is rejected.
101
+
102
+ ### Adding a provider
103
+
104
+ ```python
105
+ from sel2pw.llm.base import LLMProvider, AssistantTurn, ToolCall
106
+
107
+ class MyProvider(LLMProvider):
108
+ name = "mine"
109
+ def __init__(self, model=None, **kw):
110
+ super().__init__(model or "my-model")
111
+ def complete(self, system, messages, tools) -> AssistantTurn:
112
+ ... # convert UserMessage / AssistantTurn(raw) / ToolResultsMessage to your API, call it,
113
+ # return AssistantTurn(text, [ToolCall(id, name, args)], stop_reason, raw=<native turn>)
114
+ ```
115
+
116
+ ```bash
117
+ sel2pw migrate --provider my_pkg.providers:MyProvider ...
118
+ ```
119
+
120
+ ## MCP server
121
+
122
+ Tools: `java_inventory`, `list_files`, `read_file`, `search`, `write_file`, `edit_file`, `run_check`,
123
+ `record_mapping`, `record_issue`, `check_acceptance`, `finalize_migration` (gates + generated reports + run manifest),
124
+ `run_migration_agent` (hide with `--no-agent-tool`). Prompt: `migrate_selenium_to_playwright`.
125
+ Resource: `sel2pw://system-prompt`. The roots, `--scope` and gate options are fixed at server start, so a host
126
+ can't widen the scope. Host-driven runs get the same gates and generated reports through `finalize_migration`.
127
+
128
+ **Claude Code**
129
+
130
+ ```bash
131
+ claude mcp add sel2pw -- sel2pw mcp --source C:/work/selenium-tests --target C:/work/innoharmony-tests
132
+ ```
133
+
134
+ **VS Code / Cursor / Claude Desktop** (`mcp.json` / `claude_desktop_config.json`)
135
+
136
+ ```json
137
+ {
138
+ "mcpServers": {
139
+ "sel2pw": {
140
+ "command": "C:/myprojects/selenium2playwright/.venv/Scripts/sel2pw.exe",
141
+ "args": ["mcp", "--source", "C:/work/selenium-tests", "--target", "C:/work/innoharmony-tests"]
142
+ }
143
+ }
144
+ }
145
+ ```
146
+
147
+ HTTP transport: `sel2pw mcp ... --transport streamable-http`.
148
+
149
+ ## Tests
150
+
151
+ ```bash
152
+ pytest -q
153
+ ```
154
+
155
+ No test calls a real LLM. The provider adapters are tested against fake clients, and the MCP tests run the
156
+ server in-process.
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sel2pw"
7
+ version = "0.1.1"
8
+ description = "Selenium Java -> Python Playwright migration agent (CLI, MCP server, LLM-agnostic agent)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ dependencies = []
14
+
15
+ [project.optional-dependencies]
16
+ anthropic = ["anthropic>=1.0"]
17
+ openai = ["openai>=1.40"]
18
+ mcp = ["mcp>=2.0"]
19
+ all = ["anthropic>=1.0", "openai>=1.40", "mcp>=2.0"]
20
+ dev = ["anthropic>=1.0", "openai>=1.40", "mcp>=2.0", "pytest>=8"]
21
+
22
+ [project.scripts]
23
+ sel2pw = "sel2pw.cli:main"
24
+ sel2pw-mcp = "sel2pw.mcp_server:main"
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
28
+
29
+ [tool.setuptools.package-data]
30
+ sel2pw = ["prompts/*.md"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["tests"]
34
+ norecursedirs = ["fixtures"]
sel2pw-0.1.1/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Selenium Java -> Python Playwright migration agent."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,230 @@
1
+ """Provider-agnostic migration agent loop.
2
+
3
+ The model works through the tools; when it stops, the harness runs the
4
+ deterministic acceptance gates. Failures go back to the model as a repair
5
+ request (up to ``max_repairs`` rounds). Reports and the run manifest are then
6
+ generated by code, never by the model.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Callable
13
+
14
+ from .gates import GateReport
15
+ from .llm.base import (
16
+ AssistantTurn,
17
+ LLMProvider,
18
+ Message,
19
+ ToolDef,
20
+ ToolResult,
21
+ ToolResultsMessage,
22
+ UserMessage,
23
+ )
24
+ from .manifest import RunRecorder, sha256_json, sha256_text
25
+ from .prompts import system_prompt, task_message
26
+ from .tools import ToolBox
27
+ from .workspace import Workspace
28
+
29
+ EventHandler = Callable[[str, dict], None]
30
+
31
+ MAX_TOKEN_CONTINUATIONS = 3
32
+
33
+
34
+ @dataclass
35
+ class AgentResult:
36
+ final_text: str
37
+ stop: str # accepted | gates_failed | refusal | max_turns | max_tokens
38
+ turns: int
39
+ usage: dict[str, int] = field(default_factory=dict)
40
+ files_written: list[str] = field(default_factory=list)
41
+ modified_existing: list[str] = field(default_factory=list)
42
+ checks_run: list[str] = field(default_factory=list)
43
+ detail: str | None = None
44
+ gates: GateReport | None = None
45
+ reports: list[str] = field(default_factory=list)
46
+ repairs: int = 0
47
+ run_dir: str | None = None
48
+ fingerprint: dict = field(default_factory=dict)
49
+
50
+ @property
51
+ def accepted(self) -> bool:
52
+ return self.stop == "accepted"
53
+
54
+
55
+ class MigrationAgent:
56
+ def __init__(
57
+ self,
58
+ provider: LLMProvider,
59
+ workspace: Workspace,
60
+ *,
61
+ allow_browser_tests: bool = False,
62
+ python: str | None = None,
63
+ max_turns: int = 80,
64
+ max_repairs: int = 3,
65
+ scope: list[str] | None = None,
66
+ gate_collect: bool = False,
67
+ report_dir: str = "",
68
+ system_prompt_path: str | None = None,
69
+ record_run: bool = True,
70
+ on_event: EventHandler | None = None,
71
+ ):
72
+ self.provider = provider
73
+ self.ws = workspace
74
+ self.allow_browser_tests = allow_browser_tests
75
+ self.toolbox = ToolBox(workspace, allow_browser_tests=allow_browser_tests, python=python,
76
+ scope=scope, gate_collect=gate_collect, report_dir=report_dir)
77
+ self.max_turns = max_turns
78
+ self.max_repairs = max_repairs
79
+ self.system = system_prompt(system_prompt_path)
80
+ self.recorder = RunRecorder(workspace.root("target").path) if record_run else None
81
+ user_handler = on_event or (lambda kind, data: None)
82
+
83
+ def emit(kind: str, data: dict) -> None:
84
+ if self.recorder:
85
+ self.recorder.event(kind, data)
86
+ user_handler(kind, data)
87
+
88
+ self.on_event = emit
89
+ self.tools = [ToolDef(s.name, s.description, s.input_schema) for s in self.toolbox.specs.values()]
90
+
91
+ # -- fingerprint ---------------------------------------------------
92
+ def fingerprint(self, task_text: str) -> dict:
93
+ from .manifest import tree_hash
94
+
95
+ return {
96
+ **{f"llm.{k}": v for k, v in self.provider.fingerprint().items()},
97
+ "system_prompt_sha256": sha256_text(self.system),
98
+ "tools_sha256": sha256_json([[t.name, t.description, t.input_schema] for t in self.tools]),
99
+ "task_sha256": sha256_text(task_text),
100
+ "source_tree_sha256": tree_hash(self.ws.root("source").path),
101
+ "inventory_sha256": sha256_text(self.toolbox.inventory.to_json()),
102
+ "scope": ",".join(self.toolbox.scope or ["*"]),
103
+ "gate_collect": self.toolbox.gate_collect,
104
+ "browser_tests_allowed": self.allow_browser_tests,
105
+ }
106
+
107
+ # -- loop ----------------------------------------------------------
108
+ def run(self, task: str | None = None) -> AgentResult:
109
+ from .manifest import tree_hash
110
+
111
+ first = task_message(task, self.ws.describe(), self.allow_browser_tests, self.toolbox.scope)
112
+ fingerprint = self.fingerprint(first)
113
+ target_before = tree_hash(self.ws.root("target").path)
114
+ messages: list[Message] = [UserMessage(first)]
115
+ usage: dict[str, int] = {}
116
+ continuations = repairs = 0
117
+ last: AssistantTurn | None = None
118
+ stop, detail, gates = "max_turns", f"stopped after {self.max_turns} turns", None
119
+ turn = 0
120
+
121
+ for turn in range(1, self.max_turns + 1):
122
+ last = self.provider.complete(self.system, messages, self.tools)
123
+ messages.append(last)
124
+ for k, v in last.usage.items():
125
+ usage[k] = usage.get(k, 0) + v
126
+ self.on_event("assistant", {"turn": turn, "text": last.text, "stop": last.stop_reason, "tool_calls": len(last.tool_calls)})
127
+
128
+ if last.stop_reason == "refusal":
129
+ stop, detail = "refusal", last.detail
130
+ break
131
+ if last.stop_reason == "pause":
132
+ continue # server paused a long turn; re-send to resume
133
+
134
+ if not last.tool_calls:
135
+ if last.stop_reason == "max_tokens" and continuations < MAX_TOKEN_CONTINUATIONS:
136
+ continuations += 1
137
+ messages.append(UserMessage("Your previous reply was cut off at the output limit. Continue from where you stopped."))
138
+ continue
139
+ if last.stop_reason == "max_tokens":
140
+ stop, detail = "max_tokens", "output limit reached repeatedly"
141
+ break
142
+ gates = self.toolbox.evaluate_gates()
143
+ self.on_event("gates", {"turn": turn, "passed": gates.passed,
144
+ "failed": [r.name for r in gates.results if r.status == "fail"]})
145
+ if gates.passed:
146
+ stop, detail = "accepted", None
147
+ break
148
+ if repairs >= self.max_repairs:
149
+ stop, detail = "gates_failed", f"gates still failing after {repairs} repair round(s)"
150
+ break
151
+ repairs += 1
152
+ messages.append(UserMessage(gates.feedback()))
153
+ continue
154
+
155
+ messages.append(ToolResultsMessage(self._run_tools(turn, last)))
156
+
157
+ if gates is None or stop not in ("accepted", "gates_failed"):
158
+ gates = self.toolbox.evaluate_gates()
159
+ reports = self.toolbox.write_reports(gates, fingerprint, last.text if last else "")
160
+ result = AgentResult(
161
+ final_text=last.text if last else "",
162
+ stop=stop,
163
+ turns=turn,
164
+ usage=usage,
165
+ files_written=list(self.ws.written),
166
+ modified_existing=list(self.ws.modified_existing),
167
+ checks_run=list(self.toolbox.checks_run),
168
+ detail=detail,
169
+ gates=gates,
170
+ reports=reports,
171
+ repairs=repairs,
172
+ fingerprint=fingerprint,
173
+ )
174
+ if self.recorder:
175
+ self._write_manifest(result, target_before)
176
+ return result
177
+
178
+ def _run_tools(self, turn: int, last: AssistantTurn) -> list[ToolResult]:
179
+ results = []
180
+ for call in last.tool_calls:
181
+ if last.stop_reason == "max_tokens":
182
+ # A truncated tool input can still parse as a valid partial object - never run it.
183
+ output, is_error = (
184
+ "Not executed: your reply hit the output limit, so this tool input may be truncated. "
185
+ "Re-issue the call; split large files into smaller write_file/edit_file calls.",
186
+ True,
187
+ )
188
+ elif call.parse_error:
189
+ output, is_error = f"Not executed: {call.parse_error}. Re-issue the call with valid JSON arguments.", True
190
+ else:
191
+ self.on_event("tool_call", {"turn": turn, "name": call.name, "arguments": _summarize_args(call.arguments)})
192
+ output, is_error = self.toolbox.call(call.name, call.arguments)
193
+ self.on_event("tool_result", {"turn": turn, "name": call.name, "is_error": is_error, "preview": output[:200]})
194
+ results.append(ToolResult(call.id, call.name, output, is_error))
195
+ return results
196
+
197
+ def _write_manifest(self, result: AgentResult, target_before: str) -> None:
198
+ from .manifest import tree_hash
199
+
200
+ assert self.recorder is not None
201
+ cache = {}
202
+ if hasattr(self.provider, "hits"):
203
+ cache = {"mode": getattr(self.provider, "mode", None), "hits": self.provider.hits, "misses": self.provider.misses}
204
+ self.recorder.write("ledger.json", self.toolbox.ledger.to_dict())
205
+ self.recorder.write("gates.json", result.gates.to_dict() if result.gates else {})
206
+ self.recorder.manifest(result.fingerprint, {
207
+ "stop": result.stop,
208
+ "detail": result.detail,
209
+ "turns": result.turns,
210
+ "repairs": result.repairs,
211
+ "usage": result.usage,
212
+ "cache": cache,
213
+ "files_written": result.files_written,
214
+ "modified_existing": result.modified_existing,
215
+ "reports": result.reports,
216
+ "checks_run": result.checks_run,
217
+ "target_tree_sha256_before": target_before,
218
+ "target_tree_sha256_after": tree_hash(self.ws.root("target").path),
219
+ })
220
+ result.run_dir = str(self.recorder.run_dir)
221
+
222
+
223
+ def _summarize_args(args: object) -> dict:
224
+ if not isinstance(args, dict):
225
+ return {"_": str(args)[:80]}
226
+ out = {}
227
+ for k, v in args.items():
228
+ s = v if isinstance(v, (int, bool)) else str(v)
229
+ out[k] = s if not isinstance(s, str) or len(s) <= 80 else f"<{len(s)} chars>"
230
+ return out