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 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
- if (!["walk", "revoke"].includes(cmd) || !args[1]) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "get-handoff",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "bin": { "handoff": "./bin/handoff.mjs", "get-handoff": "./bin/handoff.mjs" },
6
6
  "files": ["bin/", "src/", "!src/**/*.test.js"],
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
- clip = await startClip(await page.context().newCDPSession(page));
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) {