@capagents/forgeqe 2.2.1 → 2.2.3
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.
- package/HOW_TO_USE.md +235 -51
- package/README.md +2 -2
- package/THIRD_PARTY_NOTICES.md +2 -1
- package/dist/forgeqe.js +1145 -951
- package/dist/template.html +36 -36
- package/docs/ARCHITECTURE.md +6 -2
- package/docs/CONTAINERS.md +80 -4
- package/docs/user-guide.html +1509 -143
- package/forgeqe.example.yaml +3 -1
- package/llms.example.json +2 -4
- package/package.json +5 -2
- package/prompts/README.md +1 -0
- package/prompts/brain.md +3 -1
- package/prompts/test-plan.md +61 -0
- package/py/forgeqe_engine/browser.py +106 -1
- package/py/forgeqe_engine/cli.py +57 -0
- package/py/forgeqe_engine/copilot_gateway.py +369 -0
- package/py/forgeqe_engine/dynamic.py +149 -0
- package/py/forgeqe_engine/recordsession.py +908 -0
- package/py/forgeqe_engine/service.py +29 -0
- package/py/forgeqe_engine/webrecord.py +589 -0
- package/py/forgeqe_engine/webrecord_page.py +530 -0
- package/reporters/playwright.mjs +83 -0
package/forgeqe.example.yaml
CHANGED
|
@@ -86,9 +86,11 @@ browser:
|
|
|
86
86
|
max_steps: 40 # a checkpoint: a run still making progress carries on, up to 3x
|
|
87
87
|
timeout_seconds: 600 # whole run
|
|
88
88
|
vision_detail: low # low | high
|
|
89
|
-
viewport:
|
|
89
|
+
viewport: # headless page size; a visible window too with window: viewport
|
|
90
90
|
width: 1280
|
|
91
91
|
height: 800
|
|
92
|
+
window: maximized # visible browser: maximized | viewport
|
|
93
|
+
video: true # video.mp4 of each run, shown in its report
|
|
92
94
|
allowed_hosts: []
|
|
93
95
|
judge: false # true: a second model call checks each finished run against the goal and can fail it
|
|
94
96
|
overlay: true # visible browser: ForgeQE start page, edge glow, a bar with the current step, your mouse and keyboard locked while the agent works
|
package/llms.example.json
CHANGED
|
@@ -50,10 +50,8 @@
|
|
|
50
50
|
},
|
|
51
51
|
"copilot": {
|
|
52
52
|
"provider": "github_copilot",
|
|
53
|
-
"model": "
|
|
54
|
-
"
|
|
55
|
-
"github_org": "",
|
|
56
|
-
"description": "GitHub Copilot seat for ForgeQE's agent and Scribe (forgeqe auth login copilot); the browser engine uses GitHub Models with your GitHub sign-in"
|
|
53
|
+
"model": "gpt-4.1",
|
|
54
|
+
"description": "GitHub Copilot seat for the browser engine, through GitHub's Copilot SDK (forgeqe auth login copilot: a fine-grained token with Copilot Requests, or the GitHub CLI's sign-in)"
|
|
57
55
|
}
|
|
58
56
|
}
|
|
59
57
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capagents/forgeqe",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.3",
|
|
4
4
|
"description": "ForgeQE: a browser agent CLI that runs goals and test cases in local Chrome or Chromium, or BrowserStack and TestMu AI/LambdaTest when configured, and turns passing runs into tests in your repo",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "module",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"bin/",
|
|
13
|
+
"reporters/",
|
|
13
14
|
"dist/",
|
|
14
15
|
"py/forgeqe_engine/*.py",
|
|
15
16
|
"py/forgeqe_desktop/*.py",
|
|
@@ -39,11 +40,13 @@
|
|
|
39
40
|
"forgeqe": "bun src/index.ts",
|
|
40
41
|
"start": "bun src/index.ts start",
|
|
41
42
|
"test": "bun test",
|
|
43
|
+
"test:release": "bun scripts/release-tests.ts",
|
|
42
44
|
"typecheck": "tsc --noEmit",
|
|
43
45
|
"build:report": "cd report-ui && npm install --no-audit --no-fund && npm run build && cp dist/index.html ../src/report/template.html",
|
|
44
46
|
"build": "bun scripts/build.ts",
|
|
45
47
|
"notices": "bun scripts/gen-notices.ts",
|
|
46
|
-
"prepack": "bun scripts/build.ts && bun scripts/gen-notices.ts"
|
|
48
|
+
"prepack": "bun scripts/build.ts && bun scripts/gen-notices.ts",
|
|
49
|
+
"prepublishOnly": "bun run typecheck && bun scripts/release-tests.ts"
|
|
47
50
|
},
|
|
48
51
|
"keywords": [
|
|
49
52
|
"forgeqe",
|
package/prompts/README.md
CHANGED
|
@@ -9,6 +9,7 @@ project; leave it as it is to keep getting ForgeQE's improvements when you updat
|
|
|
9
9
|
| test-agent.md | Scribe, the coding agent: turns a verified browser run into a test in your repository |
|
|
10
10
|
| test-rules.md | Rules for any test the agents write or change (included in test-agent, test-reviewer, heal) |
|
|
11
11
|
| test-reviewer.md | The reviewer that checks a passing test tests the right thing the right way |
|
|
12
|
+
| test-plan.md | Plans how a test you recorded by hand (`forgeqe record`) fits your repository, for you to agree before Scribe writes it |
|
|
12
13
|
| heal.md | Heals tests the application has outgrown, and tells them apart from real bugs |
|
|
13
14
|
| find-tests.md | Finds the automated tests that match a request |
|
|
14
15
|
| pr-review.md | Reviews pull requests |
|
package/prompts/brain.md
CHANGED
|
@@ -255,6 +255,8 @@ When the user asks for a regression pack, or which tests to run for a release, a
|
|
|
255
255
|
- You MUST report the tests by why they are in, the changes no test covers with the closest test for each, and the risk areas.
|
|
256
256
|
- When the user did not say where to run the pack, you MUST ask one question: on this machine, on TestMu AI, or in a pipeline. You MUST NOT run a pack until the user agrees.
|
|
257
257
|
- After a run, you MUST name the failing tests with their messages, and the tests that fail now but passed in the last run.
|
|
258
|
+
- You MUST give the triage of the run: each failing test's route (flaky, environment, test code broken, heal, or an AI re-run decides) and why. You MUST offer only what the triage says. For heal and AI re-run tests, offer heal_tests with pack. When the environment was down, offer to run the pack again first. You MUST NOT offer heal_tests for flaky, environment, or test code failures.
|
|
259
|
+
- You MUST file a bug only for an app_bug that heal_tests reports, not for a failure the triage routed.
|
|
258
260
|
|
|
259
261
|
### Writing tests
|
|
260
262
|
When the user asks, or when the setup below says that passed runs become tests:
|
|
@@ -281,7 +283,7 @@ When the user asks, or when the setup below says that passed runs become tests:
|
|
|
281
283
|
|
|
282
284
|
### Repairing broken tests
|
|
283
285
|
When the user says that tests fail, broke after a release, or need healing:
|
|
284
|
-
- You MUST call heal_tests.
|
|
286
|
+
- You MUST call heal_tests. After a regression pack run, give pack, and tests when the user named some. Otherwise give the command when the user gave one or you know it. Otherwise use the test command below, or let the healing agent find it.
|
|
285
287
|
- You MUST report the verdict for each test: healed and confirmed, an application bug, or not healed. For an application bug, give what the browser run found.
|
|
286
288
|
- You MUST also report the files that changed, and heal.md.
|
|
287
289
|
- You MUST NOT delete, skip, or make tests weaker yourself to make a run pass.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: test-plan
|
|
3
|
+
description: "Plans how a test recorded by hand fits the repository, for the tester to agree before Scribe writes it."
|
|
4
|
+
used_by: "forgeqe record, write_test for a recording"
|
|
5
|
+
kind: structured
|
|
6
|
+
---
|
|
7
|
+
{{> conventions}}
|
|
8
|
+
|
|
9
|
+
## Role
|
|
10
|
+
You plan how a test that a tester recorded by hand fits a test repository. You do not write or change files.
|
|
11
|
+
|
|
12
|
+
## Objective
|
|
13
|
+
Tell the tester where the test goes and which code it uses. The tester agrees before Scribe writes it.
|
|
14
|
+
|
|
15
|
+
## Inputs
|
|
16
|
+
- The handoff: the recorded steps in order, their locators, the tester's checks, variables, test data, and notes.
|
|
17
|
+
- The repository's test setup: the framework, the page objects, the fixtures, and the test data.
|
|
18
|
+
- The tools code_outline, code_element, and code_references. They show what the page objects already locate and do.
|
|
19
|
+
|
|
20
|
+
## Rules
|
|
21
|
+
Reading:
|
|
22
|
+
- You MUST find the page-object members and methods that already locate each element of the steps. Use the code tools.
|
|
23
|
+
- You SHOULD read only the files that the plan needs.
|
|
24
|
+
|
|
25
|
+
Where the test goes:
|
|
26
|
+
- You MUST name the test file and the test name. Follow the names and folders of the tests that are there.
|
|
27
|
+
- When an existing test already does the first steps, you MAY propose to extend it. Name that test.
|
|
28
|
+
|
|
29
|
+
Steps:
|
|
30
|
+
- For each step, you MUST say which existing member or method does it, or which new one the test adds, and where.
|
|
31
|
+
- A new member MUST go in the page object of the page where the step is.
|
|
32
|
+
- You MUST NOT change what an existing member does for the other tests.
|
|
33
|
+
|
|
34
|
+
Checks and data:
|
|
35
|
+
- For each CHECK line, you MUST say the assertion and the step after which it comes.
|
|
36
|
+
- A check that the tester gave only in words MUST stay in the plan. Say what on the page will show it.
|
|
37
|
+
- For each value marked as test data, you MUST name its source: a fixture, a data file, or a parameter.
|
|
38
|
+
- You MUST keep the secrets as environment variables.
|
|
39
|
+
|
|
40
|
+
Questions:
|
|
41
|
+
- You MUST ask a question only when the repository allows two good answers and the tester's choice changes the code.
|
|
42
|
+
- You MUST NOT ask more than 3 questions. Give each question 2 to 4 short options, the recommended option first.
|
|
43
|
+
- You MUST NOT ask what the handoff or the repository already answers.
|
|
44
|
+
|
|
45
|
+
## Output
|
|
46
|
+
The structured result: test_file, test_name, mode, steps, new members, checks, test data, questions, and a short summary.
|
|
47
|
+
|
|
48
|
+
## Examples
|
|
49
|
+
### Example 1
|
|
50
|
+
Handoff: open the shop, search "pliers", open the first result, add it to the cart, CHECK the cart badge shows "1". The repository has pages/HomePage.ts with searchFor() and no product page object.
|
|
51
|
+
- test_file tests/add-to-cart.spec.ts, test_name "adds pliers to the cart", mode new.
|
|
52
|
+
- Steps: search with HomePage.searchFor(); open the product with a new HomePage.product(name); add to the cart with a new ProductPage.addToCart() in pages/ProductPage.ts.
|
|
53
|
+
- Checks: after the add, expect ProductPage.cartQuantity to have the text "1".
|
|
54
|
+
- Questions: none.
|
|
55
|
+
|
|
56
|
+
### Example 2
|
|
57
|
+
Handoff: sign in, type the coupon "SAVE10" (test data: coupon), CHECK the total shows text like "$40.00". The repository has tests/data/coupons.json and a fixture that signs in.
|
|
58
|
+
- Steps: sign in with the signedIn fixture; type the coupon with a new CartPage.applyCoupon(code).
|
|
59
|
+
- Test data: read the coupon from tests/data/coupons.json, with SAVE10 as the first entry.
|
|
60
|
+
- Checks: after the coupon, expect CartPage.total to match /^\$\d+\.\d{2}$/.
|
|
61
|
+
- Questions: "Add the coupon test to tests/cart.spec.ts, or a new file?" Options: "tests/cart.spec.ts", "A new file".
|
|
@@ -192,7 +192,20 @@ def _testmu_caps(remote: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
|
|
195
|
+
# browser.provider docker: ForgeQE starts the browser container and passes its gateway's endpoint (with its token) here.
|
|
196
|
+
DOCKER_CDP_ENV = "FORGEQE_BROWSER_CDP_URL"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def docker_browser_launch_kwargs() -> Dict[str, Any]:
|
|
200
|
+
url = (os.getenv(DOCKER_CDP_ENV) or "").strip()
|
|
201
|
+
if not url.startswith("ws://127.0.0.1:"):
|
|
202
|
+
raise RuntimeError(f"docker: {DOCKER_CDP_ENV} is not set to the browser container's local endpoint; ForgeQE starts that container itself.")
|
|
203
|
+
return {"cdp_url": url}
|
|
204
|
+
|
|
205
|
+
|
|
195
206
|
def remote_browser_launch_kwargs(provider: str, remote: Dict[str, Any]) -> Dict[str, Any]:
|
|
207
|
+
if provider == "docker":
|
|
208
|
+
return docker_browser_launch_kwargs()
|
|
196
209
|
endpoint = str(remote.get("endpoint") or "").strip().rstrip("/")
|
|
197
210
|
if not endpoint:
|
|
198
211
|
raise RuntimeError(f"{provider}: a remote endpoint is required.")
|
|
@@ -212,6 +225,8 @@ def browser_launch_kwargs(*, provider: str, headless: bool, remote: Optional[Dic
|
|
|
212
225
|
def browser_connection_label(provider: str, launch: Dict[str, Any], remote: Optional[Dict[str, Any]] = None) -> str:
|
|
213
226
|
if provider == "local":
|
|
214
227
|
return str(launch.get("channel") or "chrome")
|
|
228
|
+
if provider == "docker":
|
|
229
|
+
return "Chromium in a container"
|
|
215
230
|
browser = str((remote or {}).get("browser") or "")
|
|
216
231
|
target = str((remote or {}).get("platform") or "").strip() or " ".join(
|
|
217
232
|
x for x in [str((remote or {}).get("os") or "").strip(), str((remote or {}).get("os_version") or (remote or {}).get("osVersion") or "").strip()] if x
|
|
@@ -296,6 +311,93 @@ async def reopen_in_fresh_tab(browser: Any, url: str) -> None:
|
|
|
296
311
|
await close.event_result(raise_if_any=False, raise_if_none=False)
|
|
297
312
|
|
|
298
313
|
|
|
314
|
+
VIDEO_FILE = "video.mp4"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _size(value: Any) -> Optional[Dict[str, int]]:
|
|
318
|
+
"""browser.viewport as [width, height], or None when it is not two sizes a screen can have."""
|
|
319
|
+
if not isinstance(value, (list, tuple)) or len(value) != 2:
|
|
320
|
+
return None
|
|
321
|
+
try:
|
|
322
|
+
w, h = int(value[0]), int(value[1])
|
|
323
|
+
except (TypeError, ValueError):
|
|
324
|
+
return None
|
|
325
|
+
return {"width": w, "height": h} if 200 <= w <= 10000 and 200 <= h <= 10000 else None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def window_launch_kwargs(browser_cfg: Dict[str, Any], *, headless: bool) -> Dict[str, Any]:
|
|
329
|
+
"""
|
|
330
|
+
The size of a local browser. Headless: the page is browser.viewport. Visible: the window is that size with
|
|
331
|
+
browser.window: viewport, else the library's screen-sized window, which `maximize_window` then maximises.
|
|
332
|
+
"""
|
|
333
|
+
if str(browser_cfg.get("provider") or "local") != "local":
|
|
334
|
+
return {}
|
|
335
|
+
size = _size(browser_cfg.get("viewport"))
|
|
336
|
+
if not size:
|
|
337
|
+
return {}
|
|
338
|
+
if headless:
|
|
339
|
+
return {"viewport": size}
|
|
340
|
+
return {"window_size": size} if browser_cfg.get("window") == "viewport" else {}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def wants_maximized(browser_cfg: Dict[str, Any], *, headless: bool) -> bool:
|
|
344
|
+
return not headless and str(browser_cfg.get("provider") or "local") == "local" and browser_cfg.get("window") != "viewport"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
async def maximize_window(browser: Any) -> bool:
|
|
348
|
+
"""Maximise the visible browser's window (the library only sizes it to the screen, under the menu bar and Dock)."""
|
|
349
|
+
try:
|
|
350
|
+
session = await browser.get_or_create_cdp_session()
|
|
351
|
+
send = browser.cdp_client.send.Browser
|
|
352
|
+
found = await send.getWindowForTarget(params={"targetId": session.target_id})
|
|
353
|
+
window_id = found["windowId"]
|
|
354
|
+
await send.setWindowBounds(params={"windowId": window_id, "bounds": {"windowState": "maximized"}})
|
|
355
|
+
# The page takes the new size a moment later; the video is sized from it.
|
|
356
|
+
for _ in range(10):
|
|
357
|
+
bounds = (await send.getWindowBounds(params={"windowId": window_id})).get("bounds") or {}
|
|
358
|
+
if bounds.get("windowState") == "maximized":
|
|
359
|
+
break
|
|
360
|
+
await asyncio.sleep(0.1)
|
|
361
|
+
await asyncio.sleep(0.3)
|
|
362
|
+
return True
|
|
363
|
+
except Exception as ex:
|
|
364
|
+
logger.debug("maximise window failed: %s", ex)
|
|
365
|
+
return False
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def video_available() -> bool:
|
|
369
|
+
try:
|
|
370
|
+
from browser_use.browser.video_recorder import IMAGEIO_AVAILABLE
|
|
371
|
+
except Exception:
|
|
372
|
+
return False
|
|
373
|
+
return bool(IMAGEIO_AVAILABLE)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
async def start_video(browser: Any, out_dir: Path) -> Optional[Path]:
|
|
377
|
+
"""Record the agent's tab to <out_dir>/video.mp4 until `stop_video`; None when it could not start."""
|
|
378
|
+
watchdog = getattr(browser, "_recording_watchdog", None)
|
|
379
|
+
if watchdog is None or not video_available():
|
|
380
|
+
return None
|
|
381
|
+
try:
|
|
382
|
+
return Path(await watchdog.start_recording(out_dir / VIDEO_FILE))
|
|
383
|
+
except Exception as ex:
|
|
384
|
+
logger.debug("video recording did not start: %s", ex)
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
async def stop_video(browser: Any) -> Optional[Path]:
|
|
389
|
+
"""Finish the video file; None when nothing was recorded."""
|
|
390
|
+
watchdog = getattr(browser, "_recording_watchdog", None)
|
|
391
|
+
if watchdog is None or not getattr(watchdog, "is_recording", False):
|
|
392
|
+
return None
|
|
393
|
+
try:
|
|
394
|
+
path = await asyncio.wait_for(watchdog.stop_recording(), timeout=120.0)
|
|
395
|
+
except Exception as ex:
|
|
396
|
+
logger.debug("video recording did not finish: %s", ex)
|
|
397
|
+
return None
|
|
398
|
+
return Path(path) if path and Path(path).is_file() and Path(path).stat().st_size else None
|
|
399
|
+
|
|
400
|
+
|
|
299
401
|
async def _shutdown_browser_instance(browser: Any) -> None:
|
|
300
402
|
"""Stop the library gracefully, falling back to kill()."""
|
|
301
403
|
if browser is None:
|
|
@@ -348,7 +450,10 @@ async def browser_profile(browser: Any, launch: Dict[str, Any], *, provider: str
|
|
|
348
450
|
"extra_http_headers": {},
|
|
349
451
|
"provider": provider,
|
|
350
452
|
}
|
|
351
|
-
if provider
|
|
453
|
+
if provider == "docker":
|
|
454
|
+
# Not a browser on another machine: the coding agent's replay and live browser use the same kind of container.
|
|
455
|
+
profile["container"] = True
|
|
456
|
+
elif provider != "local":
|
|
352
457
|
host = urlparse(str(launch.get("cdp_url") or "")).netloc
|
|
353
458
|
profile["remote_browser"] = f"{'BrowserStack' if provider == 'browserstack' else 'TestMu AI'} via {host}"
|
|
354
459
|
if remote:
|
package/py/forgeqe_engine/cli.py
CHANGED
|
@@ -9,9 +9,11 @@ Engine process protocol, used by the Bun CLI:
|
|
|
9
9
|
|
|
10
10
|
python -m forgeqe_engine run
|
|
11
11
|
python -m forgeqe_engine login (stdin: {url, logins}, then "save")
|
|
12
|
+
python -m forgeqe_engine record (stdin: {start_url, out_dir, ...}, then commands; "stop" or EOF ends it)
|
|
12
13
|
python -m forgeqe_engine doctor
|
|
13
14
|
python -m forgeqe_engine token (stdin: an llms.json profile with auth: entra; prints {token, expires_on})
|
|
14
15
|
python -m forgeqe_engine probe (stdin: an llms.json profile; prints {ok, ms, structured, vision, error?})
|
|
16
|
+
python -m forgeqe_engine copilot-gateway (GitHub Copilot as an OpenAI-style endpoint on 127.0.0.1; see copilot_gateway.py)
|
|
15
17
|
"""
|
|
16
18
|
from __future__ import annotations
|
|
17
19
|
|
|
@@ -72,6 +74,14 @@ def _doctor(out: TextIO) -> int:
|
|
|
72
74
|
info["azure_identity"] = version("azure-identity")
|
|
73
75
|
except Exception:
|
|
74
76
|
pass
|
|
77
|
+
try:
|
|
78
|
+
info["copilot_sdk"] = version("github-copilot-sdk")
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
try:
|
|
82
|
+
info["video"] = version("imageio")
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
75
85
|
try:
|
|
76
86
|
from .service import local_browser_launch_kwargs
|
|
77
87
|
|
|
@@ -155,6 +165,47 @@ def _login(out: TextIO) -> int:
|
|
|
155
165
|
return asyncio.run(main())
|
|
156
166
|
|
|
157
167
|
|
|
168
|
+
def _record(out: TextIO) -> int:
|
|
169
|
+
"""stdin line 1: the recording request (recordsession.py); later lines are commands ("stop", or JSON {"cmd": ...}); EOF stops."""
|
|
170
|
+
from .recordsession import record_web
|
|
171
|
+
|
|
172
|
+
line = sys.stdin.readline()
|
|
173
|
+
payload = json.loads(line) if line.strip() else {}
|
|
174
|
+
lock = threading.Lock()
|
|
175
|
+
|
|
176
|
+
def emit(event: Dict[str, Any]) -> None:
|
|
177
|
+
with lock:
|
|
178
|
+
out.write(json.dumps(event, ensure_ascii=False, default=str) + "\n")
|
|
179
|
+
out.flush()
|
|
180
|
+
|
|
181
|
+
async def main() -> int:
|
|
182
|
+
loop = asyncio.get_running_loop()
|
|
183
|
+
commands: asyncio.Queue = asyncio.Queue()
|
|
184
|
+
|
|
185
|
+
def watch_stdin() -> None:
|
|
186
|
+
for raw in sys.stdin:
|
|
187
|
+
text = raw.strip()
|
|
188
|
+
if not text:
|
|
189
|
+
continue
|
|
190
|
+
try:
|
|
191
|
+
cmd = json.loads(text) if text.startswith("{") else {"cmd": text.lower()}
|
|
192
|
+
except ValueError:
|
|
193
|
+
continue
|
|
194
|
+
if isinstance(cmd, dict):
|
|
195
|
+
loop.call_soon_threadsafe(commands.put_nowait, cmd)
|
|
196
|
+
loop.call_soon_threadsafe(commands.put_nowait, {"cmd": "stop"})
|
|
197
|
+
|
|
198
|
+
threading.Thread(target=watch_stdin, daemon=True).start()
|
|
199
|
+
try:
|
|
200
|
+
result = await record_web(payload, emit, commands)
|
|
201
|
+
except Exception as e:
|
|
202
|
+
result = {"success": False, "error": f"{type(e).__name__}: {e}"}
|
|
203
|
+
emit({"type": "done", "result": result})
|
|
204
|
+
return 0 if result.get("success") else 1
|
|
205
|
+
|
|
206
|
+
return asyncio.run(main())
|
|
207
|
+
|
|
208
|
+
|
|
158
209
|
def _token(out: TextIO) -> int:
|
|
159
210
|
"""stdin: an llms.json profile with auth: entra. Prints {token, expires_on} for its scope, or {error}."""
|
|
160
211
|
from .llm import entra_credential, entra_scope
|
|
@@ -204,9 +255,15 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
204
255
|
return _run(out)
|
|
205
256
|
if cmd == "login":
|
|
206
257
|
return _login(out)
|
|
258
|
+
if cmd == "record":
|
|
259
|
+
return _record(out)
|
|
207
260
|
if cmd == "token":
|
|
208
261
|
return _token(out)
|
|
209
262
|
if cmd == "probe":
|
|
210
263
|
return _probe(out)
|
|
264
|
+
if cmd == "copilot-gateway":
|
|
265
|
+
from .copilot_gateway import main as copilot_gateway
|
|
266
|
+
|
|
267
|
+
return copilot_gateway(out)
|
|
211
268
|
out.write(json.dumps({"type": "done", "result": {"success": False, "error": f"Unknown command {cmd}"}}) + "\n")
|
|
212
269
|
return 2
|