get-handoff 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/handoff.mjs +49 -3
- package/package.json +1 -1
- package/src/api.js +38 -0
- package/src/walk.js +4 -2
package/bin/handoff.mjs
CHANGED
|
@@ -5,17 +5,22 @@ import { parsePlan } from "../src/plan.js";
|
|
|
5
5
|
import { openBrowser } from "../src/browser.js";
|
|
6
6
|
import { walkPlan } from "../src/walk.js";
|
|
7
7
|
import { buildPayload } from "../src/payload.js";
|
|
8
|
-
import { uploadFrames, uploadClips, createDraft, revokeWalkthrough } from "../src/api.js";
|
|
8
|
+
import { uploadFrames, uploadClips, createDraft, revokeWalkthrough, publishWalkthrough, listWalkthroughs, pruneWalkthroughs } from "../src/api.js";
|
|
9
9
|
import { readGitContext } from "../src/git.js";
|
|
10
10
|
|
|
11
11
|
const USAGE = `handoff walk <план.md> [опции]
|
|
12
|
+
handoff publish <wlk_id> [--summary <файл.json>] — опубликовать черновик (и прикрепить сводку)
|
|
13
|
+
handoff list — показать свои прогоны (снятые и черновики)
|
|
14
|
+
handoff prune — удалить свои ЧЕРНОВИКИ (опубликованное не трогается)
|
|
12
15
|
handoff revoke <слаг|id> — закрыть доступ к уже разосланной ссылке
|
|
13
16
|
|
|
14
17
|
--attach <url> подключиться к запущенному браузеру (по умолчанию — свой профиль)
|
|
15
18
|
--headless не показывать окно
|
|
16
19
|
--settle <мс> пауза после каждого шага (или HANDOFF_SETTLE; медленным SPA — больше)
|
|
20
|
+
--no-clip не писать клип-запись (страница её не читает — быстрее и легче)
|
|
17
21
|
--fail-fast не создавать черновик, если хоть один шаг провалился
|
|
18
22
|
--out <файл> записать тело запроса в файл вместо создания черновика
|
|
23
|
+
--summary <файл> (для publish) JSON-массив [{stepIdx, text}, …] — по пункту на шаг
|
|
19
24
|
--api <url> база edge-функций (или HANDOFF_API)
|
|
20
25
|
--key <ключ> agent key (или HANDOFF_KEY)
|
|
21
26
|
-h, --help показать эту справку
|
|
@@ -36,7 +41,8 @@ if (!cmd || cmd === "--help" || cmd === "-h" || args.includes("--help") || args.
|
|
|
36
41
|
console.log(USAGE);
|
|
37
42
|
process.exit(0);
|
|
38
43
|
}
|
|
39
|
-
|
|
44
|
+
const NEEDS_ARG = ["walk", "publish", "revoke"]; // list/prune аргумента не требуют
|
|
45
|
+
if (!["walk", "publish", "revoke", "list", "prune"].includes(cmd) || (NEEDS_ARG.includes(cmd) && !args[1])) {
|
|
40
46
|
console.error(USAGE);
|
|
41
47
|
process.exit(1);
|
|
42
48
|
}
|
|
@@ -65,6 +71,45 @@ if (cmd === "revoke") {
|
|
|
65
71
|
process.exit(0);
|
|
66
72
|
}
|
|
67
73
|
|
|
74
|
+
// Публикация из CLI — гомогенно с revoke (браузер не нужен). Сводку читаем из
|
|
75
|
+
// JSON-файла: [{stepIdx, text}, …], по пункту на шаг. Без --summary просто
|
|
76
|
+
// публикуем; повторный вызов со свежей сводкой обновляет её на месте.
|
|
77
|
+
if (cmd === "publish") {
|
|
78
|
+
if (!cfgEarly.agentKey) {
|
|
79
|
+
console.error("Нужен agent key: --key или HANDOFF_KEY.");
|
|
80
|
+
process.exit(2);
|
|
81
|
+
}
|
|
82
|
+
const summaryFile = opt("summary");
|
|
83
|
+
let summary;
|
|
84
|
+
if (summaryFile) {
|
|
85
|
+
try { summary = JSON.parse(readFileSync(summaryFile, "utf8")); }
|
|
86
|
+
catch (e) { console.error(`не прочитать summary ${summaryFile}: ${e.message}`); process.exit(2); }
|
|
87
|
+
}
|
|
88
|
+
const res = await publishWalkthrough(args[1], summary, cfgEarly);
|
|
89
|
+
console.log(res.alreadyPublished
|
|
90
|
+
? `Обновлено: ${res.url}${summary ? " (сводка обновлена)" : ""}`
|
|
91
|
+
: `Опубликовано: ${res.url}`);
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (cmd === "list") {
|
|
96
|
+
if (!cfgEarly.agentKey) { console.error("Нужен agent key: --key или HANDOFF_KEY."); process.exit(2); }
|
|
97
|
+
const items = await listWalkthroughs(cfgEarly);
|
|
98
|
+
if (items.length === 0) { console.log("Прогонов пока нет."); process.exit(0); }
|
|
99
|
+
for (const w of items) {
|
|
100
|
+
const tail = w.url ? ` ${w.url}` : "";
|
|
101
|
+
console.log(` ${w.status.padEnd(10)} ${w.walkthroughId} ${w.planKey ?? "—"} «${w.title}»${tail}`);
|
|
102
|
+
}
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (cmd === "prune") {
|
|
107
|
+
if (!cfgEarly.agentKey) { console.error("Нужен agent key: --key или HANDOFF_KEY."); process.exit(2); }
|
|
108
|
+
const n = await pruneWalkthroughs(cfgEarly);
|
|
109
|
+
console.log(n === 0 ? "Черновиков на удаление нет." : `Удалено черновиков: ${n} (опубликованное не тронуто).`);
|
|
110
|
+
process.exit(0);
|
|
111
|
+
}
|
|
112
|
+
|
|
68
113
|
const plan = parsePlan(readFileSync(args[1], "utf8"));
|
|
69
114
|
// Файл плана — это и есть личность прогона: `help.md` остаётся `help.md`,
|
|
70
115
|
// как бы ни звучал заголовок внутри.
|
|
@@ -80,11 +125,12 @@ const settleRaw = opt("settle", process.env.HANDOFF_SETTLE);
|
|
|
80
125
|
const settle = settleRaw != null && settleRaw !== "" && Number.isFinite(Number(settleRaw))
|
|
81
126
|
? Number(settleRaw) : undefined;
|
|
82
127
|
|
|
128
|
+
const recordClip = !has("no-clip");
|
|
83
129
|
console.log(`Прогон «${plan.title}» — ${plan.steps.length} шагов на ${plan.origin}`);
|
|
84
130
|
const { page, close } = await openBrowser({ attach: opt("attach"), headless: has("headless") });
|
|
85
131
|
let shots;
|
|
86
132
|
try {
|
|
87
|
-
shots = await walkPlan(page, plan, { settle });
|
|
133
|
+
shots = await walkPlan(page, plan, { settle, recordClip });
|
|
88
134
|
} finally {
|
|
89
135
|
await close();
|
|
90
136
|
}
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -67,6 +67,44 @@ export async function createDraft(payload, cfg, fetchImpl = fetch) {
|
|
|
67
67
|
return body;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// Публикация черновика со сводкой — тот же контракт, что MCP publish_walkthrough
|
|
71
|
+
// (сводку валидирует сервер: по пункту на шаг, покрыты все). summary можно не
|
|
72
|
+
// передавать — тогда просто публикуем; повторный вызов обновляет сводку на месте.
|
|
73
|
+
export async function publishWalkthrough(walkthroughId, summary, cfg, fetchImpl = fetch) {
|
|
74
|
+
const res = await fetchImpl(`${cfg.apiBase}/walkthrough-publish`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { Authorization: `Bearer ${cfg.agentKey}`, "content-type": "application/json" },
|
|
77
|
+
body: JSON.stringify(summary === undefined ? { walkthroughId } : { walkthroughId, summary }),
|
|
78
|
+
});
|
|
79
|
+
const data = await res.json().catch(() => ({}));
|
|
80
|
+
if (!res.ok) throw new Error(`не удалось опубликовать: ${data.error ?? res.status}`);
|
|
81
|
+
return data;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Список прогонов этого ключа (что снято, что осталось черновиком).
|
|
85
|
+
export async function listWalkthroughs(cfg, fetchImpl = fetch) {
|
|
86
|
+
const res = await fetchImpl(`${cfg.apiBase}/walkthrough-list`, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { Authorization: `Bearer ${cfg.agentKey}`, "content-type": "application/json" },
|
|
89
|
+
body: "{}",
|
|
90
|
+
});
|
|
91
|
+
const data = await res.json().catch(() => ({}));
|
|
92
|
+
if (!res.ok) throw new Error(`не удалось получить список: ${data.error ?? res.status}`);
|
|
93
|
+
return data.walkthroughs ?? [];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Подчистить черновики этого ключа (опубликованное не трогается).
|
|
97
|
+
export async function pruneWalkthroughs(cfg, fetchImpl = fetch) {
|
|
98
|
+
const res = await fetchImpl(`${cfg.apiBase}/walkthrough-prune`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: { Authorization: `Bearer ${cfg.agentKey}`, "content-type": "application/json" },
|
|
101
|
+
body: "{}",
|
|
102
|
+
});
|
|
103
|
+
const data = await res.json().catch(() => ({}));
|
|
104
|
+
if (!res.ok) throw new Error(`не удалось подчистить: ${data.error ?? res.status}`);
|
|
105
|
+
return data.pruned ?? 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
70
108
|
// Отзыв ссылки: единственная кнопка «стоп» для прогона, который уже разослали.
|
|
71
109
|
// Принимает слаг из адреса или id черновика — что оказалось под рукой.
|
|
72
110
|
export async function revokeWalkthrough(target, cfg, fetchImpl = fetch) {
|
package/src/walk.js
CHANGED
|
@@ -18,7 +18,7 @@ const NETIDLE_MS = 8000;
|
|
|
18
18
|
const DOM_SAMPLES = 8;
|
|
19
19
|
const DOM_GAP_MS = 150;
|
|
20
20
|
|
|
21
|
-
export async function walkPlan(page, plan, { capture = captureStep, settle = SETTLE_MS } = {}) {
|
|
21
|
+
export async function walkPlan(page, plan, { capture = captureStep, settle = SETTLE_MS, recordClip = true } = {}) {
|
|
22
22
|
const shots = [];
|
|
23
23
|
for (const step of plan.steps) {
|
|
24
24
|
// `wait` только ждёт — кадр НЕ снимается (медленным страницам нужно дать
|
|
@@ -42,7 +42,9 @@ export async function walkPlan(page, plan, { capture = captureStep, settle = SET
|
|
|
42
42
|
// шаг: не завелась запись — шаг всё равно снимется стоп-кадром.
|
|
43
43
|
try {
|
|
44
44
|
if (withCursor) await page.evaluate(installGhost);
|
|
45
|
-
|
|
45
|
+
// Клип (screencast) — украшение, которое СТРАНИЦА ДАЖЕ НЕ ЧИТАЕТ (жест
|
|
46
|
+
// синтетический, по координатам). --no-clip его не пишет: быстрее и не жжёт CPU.
|
|
47
|
+
clip = recordClip ? await startClip(await page.context().newCDPSession(page)) : null;
|
|
46
48
|
if (withCursor) {
|
|
47
49
|
const box = await page.locator(step.target).first().boundingBox().catch(() => null);
|
|
48
50
|
if (box) {
|