@itpay/cli 0.2.16 → 2.0.0-rc.1

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 (69) hide show
  1. package/README.md +148 -447
  2. package/bin/itp +1 -150
  3. package/dist/src/client/backend.js +154 -0
  4. package/dist/src/client/http.js +76 -0
  5. package/dist/src/client/types.js +4 -0
  6. package/dist/src/commands/buy.js +351 -0
  7. package/dist/src/commands/cart.js +264 -0
  8. package/dist/src/commands/catalog.js +26 -0
  9. package/dist/src/commands/checkout.js +106 -0
  10. package/dist/src/commands/docs.js +61 -0
  11. package/dist/src/commands/guidance.js +422 -0
  12. package/dist/src/commands/install.js +95 -0
  13. package/dist/src/commands/order.js +78 -0
  14. package/dist/src/commands/orders.js +22 -0
  15. package/dist/src/commands/pay.js +26 -0
  16. package/dist/src/commands/readyz.js +8 -0
  17. package/dist/src/commands/refund.js +20 -0
  18. package/dist/src/commands/services.js +317 -0
  19. package/dist/src/main.js +606 -0
  20. package/dist/src/render/feishu.js +201 -0
  21. package/dist/src/render/ide.js +321 -0
  22. package/dist/src/render/index.js +57 -0
  23. package/dist/src/render/interaction.js +49 -0
  24. package/dist/src/render/markdown.js +83 -0
  25. package/dist/src/render/output.js +42 -0
  26. package/dist/src/render/plain_chat.js +60 -0
  27. package/dist/src/render/plan.js +31 -0
  28. package/dist/src/render/qr.js +32 -0
  29. package/dist/src/render/sink.js +6 -0
  30. package/dist/src/render/status.js +37 -0
  31. package/dist/src/render/telegram.js +172 -0
  32. package/dist/src/render/terminal.js +148 -0
  33. package/dist/src/render/terminal_image.js +19 -0
  34. package/dist/src/state/cart_session.js +151 -0
  35. package/dist/src/state/client_context.js +73 -0
  36. package/dist/src/state/config.js +82 -0
  37. package/dist/src/state/device_authority.js +217 -0
  38. package/dist/src/state/operation_journal.js +80 -0
  39. package/docs/agent/buyer/cart-checkout.json +56 -94
  40. package/docs/agent/buyer/catalog-list.json +47 -0
  41. package/docs/agent/buyer/install-and-setup.json +82 -0
  42. package/docs/agent/buyer/orders-refunds.json +76 -0
  43. package/docs/agent/buyer/payment-flow.json +77 -0
  44. package/docs/agent/buyer/quickstart.json +143 -75
  45. package/docs/agent/buyer/render-hosts.json +79 -0
  46. package/package.json +32 -13
  47. package/skills/itpay-buyer/SKILL.md +107 -238
  48. package/docs/agent/buyer/account-portal.json +0 -81
  49. package/docs/agent/buyer/catalog-search.json +0 -106
  50. package/docs/agent/buyer/human-claim-ui.json +0 -77
  51. package/docs/agent/buyer/payment-qr.json +0 -97
  52. package/docs/agent/buyer/payment-wait.json +0 -84
  53. package/docs/agent/buyer/product-recommendation.json +0 -80
  54. package/docs/agent/buyer/qr-refresh.json +0 -67
  55. package/docs/agent/buyer/recovery.json +0 -85
  56. package/docs/agent/buyer/safety-policy.json +0 -70
  57. package/docs/agent/buyer/secure-delivery.json +0 -90
  58. package/docs/agent/buyer/vault-agent-read.json +0 -95
  59. package/install.ps1 +0 -65
  60. package/install.sh +0 -66
  61. package/lib/account-status.js +0 -157
  62. package/lib/buyer.js +0 -2332
  63. package/lib/client-context.js +0 -126
  64. package/lib/docs.js +0 -200
  65. package/lib/env.js +0 -723
  66. package/lib/http.js +0 -151
  67. package/lib/ops.js +0 -135
  68. package/lib/render-human.js +0 -718
  69. package/lib/runtime.js +0 -1456
@@ -0,0 +1,201 @@
1
+ // Feishu / Lark renderer for the V3 CLI. V1 had no Feishu support
2
+ // at all, so this is a fresh contract built for V3. We use Feishu's
3
+ // Interactive Card 1.0 message format: a top-level `header` + an
4
+ // `elements` array with text, image and action (button) blocks.
5
+ //
6
+ // Buttons are split between URL actions (deep link the buyer taps)
7
+ // and "interactive callback" actions (postback the bot receives).
8
+ // The host adapter (Lingo or your own bot) translates a `callback`
9
+ // intent into a `/v1/checkouts/{id}/...` follow-up call to the V3
10
+ // backend, mirroring how Telegram `itp:refresh_payment_qr:<id>` is
11
+ // handled in V1.
12
+ import { ideImageAttachBlock } from "./ide.js";
13
+ function actionFor(plan, button) {
14
+ if (button.kind === "url" && button.url) {
15
+ return {
16
+ tag: "action",
17
+ actions: [
18
+ {
19
+ tag: "button",
20
+ text: { tag: "plain_text", content: button.label },
21
+ type: "primary",
22
+ url: button.url,
23
+ },
24
+ ],
25
+ };
26
+ }
27
+ return {
28
+ tag: "action",
29
+ actions: [
30
+ {
31
+ tag: "button",
32
+ text: { tag: "plain_text", content: button.label },
33
+ type: "default",
34
+ value: {
35
+ intent: button.intent ?? "noop",
36
+ ref: button.ref ?? "",
37
+ checkout_id: plan.checkoutID ?? "",
38
+ payment_intent_id: plan.paymentIntentID ?? "",
39
+ },
40
+ },
41
+ ],
42
+ };
43
+ }
44
+ function buttonsFor(plan) {
45
+ if (plan.kind === "payment_qr" && plan.paymentIntentID) {
46
+ return [
47
+ { label: "支付遇到问题 / 刷新", kind: "callback", intent: "refresh_payment_qr", ref: plan.paymentIntentID },
48
+ { label: "我已付款,查询状态", kind: "callback", intent: "check_payment_status", ref: plan.paymentIntentID },
49
+ ];
50
+ }
51
+ if (plan.kind === "auth_qr" && plan.checkoutID) {
52
+ return [
53
+ { label: "打开授权页面", kind: "url", url: plan.url },
54
+ { label: "查询授权状态", kind: "callback", intent: "check_checkout_status", ref: plan.checkoutID },
55
+ ];
56
+ }
57
+ return [
58
+ { label: "打开 ItPay 收银台", kind: "url", url: plan.url },
59
+ ...(plan.checkoutID
60
+ ? [{ label: "查询 Checkout 状态", kind: "callback", intent: "check_checkout_status", ref: plan.checkoutID }]
61
+ : []),
62
+ ];
63
+ }
64
+ export function renderFeishu(plan, options) {
65
+ const out = options.output ?? ((line) => process.stdout.write(line));
66
+ const host = options.host ?? "feishu";
67
+ const buttons = buttonsFor(plan);
68
+ const media = collectFeishuMedia(plan);
69
+ const title = plan.kind === "payment_qr"
70
+ ? "ItPay 支付二维码"
71
+ : plan.kind === "auth_qr"
72
+ ? "ItPay 需要买家授权"
73
+ : "ItPay Checkout 二维码";
74
+ const card = {
75
+ config: { wide_screen_mode: true },
76
+ header: {
77
+ template: "blue",
78
+ title: { tag: "plain_text", content: title },
79
+ },
80
+ elements: [
81
+ {
82
+ tag: "div",
83
+ text: { tag: "plain_text", content: plan.summary },
84
+ },
85
+ ...media.map((item) => ({
86
+ tag: "img",
87
+ img_key: item.url,
88
+ alt: { tag: "plain_text", content: item.alt ?? item.label ?? `${title} (image)` },
89
+ })),
90
+ {
91
+ tag: "note",
92
+ elements: [
93
+ { tag: "plain_text", content: `checkout_id: ${plan.checkoutID ?? "-"}` },
94
+ ],
95
+ },
96
+ ...buttons.map((button) => actionFor(plan, button)),
97
+ ],
98
+ };
99
+ const message = {
100
+ host,
101
+ target: options.target,
102
+ receive_id_type: host === "lark" ? "open_id" : "chat_id",
103
+ msg_type: "interactive",
104
+ card,
105
+ ...(plan.ideImageAttach
106
+ ? {
107
+ ide_image_attach: {
108
+ status: plan.ideImageAttach.status,
109
+ local_path: plan.ideImageAttach.localPath,
110
+ mirrors: plan.ideImageAttach.mirrors,
111
+ mime_type: plan.ideImageAttach.mimeType,
112
+ source: plan.ideImageAttach.source,
113
+ ...(plan.ideImageAttach.caption ? { caption: plan.ideImageAttach.caption } : {}),
114
+ must_render_reason: plan.ideImageAttach.mustRenderReason,
115
+ ...(plan.ideImageAttach.error ? { error: plan.ideImageAttach.error } : {}),
116
+ action: "agent_must_render_into_ide_chat",
117
+ instructions: ideImageAttachBlock(plan.ideImageAttach).filter((l) => l.length > 0),
118
+ },
119
+ }
120
+ : {}),
121
+ };
122
+ out(JSON.stringify({ message }, null, 2) + "\n");
123
+ }
124
+ export function renderFeishuInteraction(request, options) {
125
+ const out = options.output ?? ((line) => process.stdout.write(line));
126
+ const host = options.host ?? "feishu";
127
+ const buttons = request.kind === "selector"
128
+ ? request.options.map((option) => selectorButton(request.id, option))
129
+ : [];
130
+ const card = {
131
+ config: { wide_screen_mode: true },
132
+ header: {
133
+ template: "blue",
134
+ title: { tag: "plain_text", content: request.title },
135
+ },
136
+ elements: [
137
+ {
138
+ tag: "div",
139
+ text: { tag: "plain_text", content: request.prompt },
140
+ },
141
+ ...(request.media ?? []).map((item) => ({
142
+ tag: "img",
143
+ img_key: item.url,
144
+ alt: { tag: "plain_text", content: item.alt ?? item.label ?? request.title },
145
+ })),
146
+ ...(request.kind === "input"
147
+ ? request.fields.map((field) => ({
148
+ tag: "note",
149
+ elements: [
150
+ {
151
+ tag: "plain_text",
152
+ content: `${field.label} (${field.id}, ${field.inputType}${field.required ? ", required" : ""})`,
153
+ },
154
+ ],
155
+ }))
156
+ : buttons.map((button) => actionFor({ checkoutID: "", paymentIntentID: "" }, button))),
157
+ ],
158
+ };
159
+ const message = {
160
+ host,
161
+ target: options.target,
162
+ receive_id_type: host === "lark" ? "open_id" : "chat_id",
163
+ msg_type: "interactive",
164
+ card,
165
+ ...(request.kind === "input"
166
+ ? {
167
+ input_request: {
168
+ type: "itpay_input_request",
169
+ id: request.id,
170
+ submit_label: request.submitLabel ?? "Submit",
171
+ fields: request.fields,
172
+ },
173
+ }
174
+ : {
175
+ selector_request: {
176
+ type: "itpay_selector_request",
177
+ id: request.id,
178
+ selection_mode: request.selectionMode ?? "single",
179
+ submit_label: request.submitLabel ?? "Confirm",
180
+ options: request.options,
181
+ },
182
+ }),
183
+ };
184
+ out(JSON.stringify({ message }, null, 2) + "\n");
185
+ }
186
+ function collectFeishuMedia(plan) {
187
+ const media = [...(plan.platform.media ?? [])];
188
+ const brand = plan.preferredQRSources.find((src) => src.length > 0);
189
+ if (brand) {
190
+ media.unshift({ url: brand, label: "QR image", alt: "ItPay QR" });
191
+ }
192
+ return media;
193
+ }
194
+ function selectorButton(requestID, option) {
195
+ return {
196
+ label: option.label,
197
+ kind: "callback",
198
+ intent: "submit_selector_option",
199
+ ref: `${requestID}:${option.id}`,
200
+ };
201
+ }
@@ -0,0 +1,321 @@
1
+ // IDE image-attach contract.
2
+ //
3
+ // The V3 backend serves a brand QR PNG that must end up visible inside
4
+ // the IDE chat window (Trae `Read` tool, Codex, Claude Code) because
5
+ // the human can only scan it from there. This module is the single
6
+ // source of truth for the download → local file → plan.attach flow:
7
+ //
8
+ // 1. download the brand image from the backend
9
+ // 2. write it to one canonical directory + an `/tmp`-style alias
10
+ // when the filesystem permits (covers macOS sandbox which exposes
11
+ // a private $TMPDIR under /var/folders/... but IDE tooling often
12
+ // still probes /tmp first)
13
+ // 3. mirror the file under a stable filename per (kind, id) so
14
+ // re-runs from the same checkout land on the same path
15
+ // 4. atomic write (write-temp + rename) for crash safety
16
+ // 5. format fallback via magic-byte sniffing so SVG / WebP / no-suffix
17
+ // CDN URLs still produce a readable local file
18
+ // 6. errors never get swallowed: the plan still gets an attach with
19
+ // `status: "failed"` and a populated `error` field, so JSON
20
+ // callers and renderers can surface the failure instead of
21
+ // silently dropping the image
22
+ // 7. the whole feature can be disabled via ITPAY_IDE_IMAGE_ATTACH=0
23
+ // for read-only sandbox runners; in that case `status: "disabled"`
24
+ //
25
+ // Every renderer + the JSON output consume the same `ideImageAttach`
26
+ // field on the plan, so the IDE contract is consistent regardless of
27
+ // the host.
28
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
29
+ import { join } from "node:path";
30
+ import { tmpdir } from "node:os";
31
+ import { createHash, randomUUID } from "node:crypto";
32
+ const IDE_ATTACH_REASON = "Human cannot scan a remote URL: the IDE chat window is the only " +
33
+ "surface they can pay from. The agent MUST render this PNG into the " +
34
+ "IDE chat (Trae `Read` tool with the localPath or one of the mirrors) " +
35
+ "and wait for the human to confirm they see + scan it. If the image is " +
36
+ "not visible in chat, the checkout is considered failed — re-render " +
37
+ "and ask again, do not mark the order complete.";
38
+ // Resolve the canonical IDE QR directory. Override via
39
+ // ITPAY_IDE_IMAGE_DIR_OVERRIDE; default to `tmpdir()/itpay-v3-qr`.
40
+ function canonicalDir(env = process.env) {
41
+ const override = env.ITPAY_IDE_IMAGE_DIR_OVERRIDE;
42
+ if (override && override.length > 0)
43
+ return override;
44
+ return join(tmpdir(), "itpay-v3-qr");
45
+ }
46
+ // Compute alias directories the file is also mirrored to. Today the
47
+ // only alias we care about is `/tmp/itpay-v3-qr` — IDE tooling paths
48
+ // are written against that well-known temp dir. We only mirror when
49
+ // /tmp is actually a different location from `tmpdir()` and is writable
50
+ // or creatable; otherwise we skip silently.
51
+ export function resolveIdeImageDirs(env = process.env) {
52
+ const canonical = canonicalDir(env);
53
+ const dirs = [canonical];
54
+ if (process.platform === "win32")
55
+ return dirs;
56
+ const tmps = "/tmp/itpay-v3-qr";
57
+ if (tmps === canonical)
58
+ return dirs;
59
+ // Best-effort: probe or create the alias dir. If the filesystem
60
+ // refuses, we silently skip (the canonical location is still
61
+ // authoritative).
62
+ try {
63
+ mkdirSync(tmps, { recursive: true });
64
+ dirs.push(tmps);
65
+ }
66
+ catch {
67
+ /* ignore */
68
+ }
69
+ return dirs;
70
+ }
71
+ // Stable filename per (kind, id) so re-runs of the same checkout land
72
+ // on the same path. The short hash suffix disambiguates IDs that
73
+ // happen to share a checkout prefix (e.g. checkout + payment-intent
74
+ // for the same id).
75
+ function stableNameFor(kind, id) {
76
+ const safeID = id.replace(/[^a-zA-Z0-9_-]/g, "_") || randomUUID();
77
+ const hash = createHash("sha1").update(`${kind}:${id}`).digest("hex").slice(0, 6);
78
+ return `itpay-v3-${kind}-${safeID}-${hash}`;
79
+ }
80
+ async function detectImageFormat(url, fetchFn) {
81
+ const r = await fetchFn(url);
82
+ if (!r.ok)
83
+ throw new Error(`http=${r.status}`);
84
+ const ab = await r.arrayBuffer();
85
+ const buf = Buffer.from(ab);
86
+ if (buf.length >= 2 && buf[0] === 0x89 && buf[1] === 0x50) {
87
+ return { ext: ".png", body: buf };
88
+ }
89
+ if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xd8) {
90
+ return { ext: ".jpg", body: buf };
91
+ }
92
+ if (buf.length >= 4 && buf.toString("ascii", 0, 4) === "RIFF") {
93
+ return { ext: ".webp", body: buf };
94
+ }
95
+ if (buf.length >= 4 && buf.toString("ascii", 0, 4) === "<svg") {
96
+ return { ext: ".svg", body: buf };
97
+ }
98
+ if (buf.length >= 5 && buf.toString("ascii", 0, 5) === "<?xml") {
99
+ return { ext: ".svg", body: buf };
100
+ }
101
+ const ct = (r.headers.get("content-type") ?? "").toLowerCase();
102
+ if (ct.includes("svg+xml") || ct.includes("svg"))
103
+ return { ext: ".svg", body: buf };
104
+ if (ct.includes("webp"))
105
+ return { ext: ".webp", body: buf };
106
+ if (ct.includes("jpeg") || ct.includes("jpg"))
107
+ return { ext: ".jpg", body: buf };
108
+ if (ct.includes("png"))
109
+ return { ext: ".png", body: buf };
110
+ // last resort — treat as PNG to keep the IDE image viewer happy
111
+ return { ext: ".png", body: buf };
112
+ }
113
+ function atomicWrite(filePath, body) {
114
+ const tmp = `${filePath}.tmp-${randomUUID()}`;
115
+ writeFileSync(tmp, body);
116
+ renameSync(tmp, filePath);
117
+ }
118
+ function mirrorToDirs(fileName, body, dirs) {
119
+ const written = [];
120
+ for (const dir of dirs.slice(1)) {
121
+ try {
122
+ mkdirSync(dir, { recursive: true });
123
+ const target = join(dir, fileName);
124
+ atomicWrite(target, body);
125
+ written.push(target);
126
+ }
127
+ catch {
128
+ /* ignore best-effort mirror */
129
+ }
130
+ }
131
+ return written;
132
+ }
133
+ function mimeFor(ext) {
134
+ switch (ext) {
135
+ case ".png":
136
+ return "image/png";
137
+ case ".jpg":
138
+ return "image/jpeg";
139
+ case ".webp":
140
+ return "image/webp";
141
+ case ".svg":
142
+ return "image/svg+xml";
143
+ }
144
+ }
145
+ // Resolve the absolute URL the fetcher will hit. Behaviour:
146
+ // - absolute URL → return as-is (or rewrite host if baseURL matches)
147
+ // - scheme-relative URL (`//host/...`) → apply baseURL host
148
+ // - path-only (`/v1/...`) → prefix with baseURL origin
149
+ // We can't blindly `new URL(url)` because the input might use a
150
+ // scheme that the runtime considers invalid.
151
+ function resolveFetchURL(url, baseURL) {
152
+ if (!baseURL)
153
+ return url;
154
+ if (/^https?:\/\//.test(url)) {
155
+ return url.replace(/^https?:\/\/[^/]+/, baseURL);
156
+ }
157
+ if (url.startsWith("//")) {
158
+ return new URL(url, baseURL).toString();
159
+ }
160
+ if (url.startsWith("/")) {
161
+ try {
162
+ return new URL(url, baseURL).toString();
163
+ }
164
+ catch {
165
+ return url;
166
+ }
167
+ }
168
+ return url;
169
+ }
170
+ export async function downloadBrandQRToTmp(url, kind, id, options = {}) {
171
+ if (!url) {
172
+ return { ok: false, reason: "no qr_png_url on plan" };
173
+ }
174
+ const stem = stableNameFor(kind, id ?? randomUUID());
175
+ const dirs = resolveIdeImageDirs();
176
+ const canonical = dirs[0];
177
+ if (!canonical) {
178
+ return { ok: false, reason: "no IDE image directory resolved" };
179
+ }
180
+ try {
181
+ mkdirSync(canonical, { recursive: true });
182
+ }
183
+ catch (error) {
184
+ return { ok: false, reason: `mkdir ${canonical} failed: ${error.message}` };
185
+ }
186
+ const fetchURL = resolveFetchURL(url, options.baseURL);
187
+ try {
188
+ const fetchFn = options.fetchImpl ?? globalThis.fetch;
189
+ const detected = await detectImageFormat(fetchURL, fetchFn);
190
+ const fileName = `${stem}${detected.ext}`;
191
+ const filePath = join(canonical, fileName);
192
+ atomicWrite(filePath, detected.body);
193
+ const mirrors = mirrorToDirs(fileName, detected.body, dirs);
194
+ return {
195
+ ok: true,
196
+ attach: {
197
+ localPath: filePath,
198
+ mirrors,
199
+ mimeType: mimeFor(detected.ext),
200
+ source: url,
201
+ status: "downloaded",
202
+ ...(options.caption ? { caption: options.caption } : {}),
203
+ mustRenderReason: IDE_ATTACH_REASON,
204
+ },
205
+ };
206
+ }
207
+ catch (error) {
208
+ return { ok: false, reason: `brand image fetch failed: ${error.message}` };
209
+ }
210
+ }
211
+ // Single source of truth for IDE attach bootstrap. Idempotent: if the
212
+ // plan already has a non-empty `ideImageAttach`, this is a no-op. When
213
+ // `options.enabled === false`, stamp the plan with `status:"disabled"`
214
+ // so JSON callers can see the opt-out. When the download fails, the
215
+ // plan gets `status:"failed"` plus the error reason — never a silent
216
+ // no-op.
217
+ export async function ensureIdeImageAttach(plan, options = {}) {
218
+ if (plan.ideImageAttach) {
219
+ // Honor a previous successful attach: only bail when status is final.
220
+ if (plan.ideImageAttach.status === "downloaded" || plan.ideImageAttach.status === "disabled") {
221
+ return;
222
+ }
223
+ }
224
+ if (options.enabled === false) {
225
+ plan.ideImageAttach = {
226
+ localPath: "",
227
+ mirrors: [],
228
+ mimeType: "image/png",
229
+ source: "",
230
+ status: "disabled",
231
+ mustRenderReason: IDE_ATTACH_REASON,
232
+ };
233
+ return;
234
+ }
235
+ const pngURL = plan.preferredQRSources.find((src) => src.length > 0);
236
+ if (!pngURL) {
237
+ plan.ideImageAttach = {
238
+ localPath: "",
239
+ mirrors: [],
240
+ mimeType: "image/png",
241
+ source: "",
242
+ status: "failed",
243
+ mustRenderReason: IDE_ATTACH_REASON,
244
+ };
245
+ return;
246
+ }
247
+ const kind = plan.kind === "payment_qr" ? "payment" : "checkout";
248
+ const id = plan.paymentIntentID ?? plan.checkoutID;
249
+ const caption = plan.kind === "payment_qr" ? "ItPay brand payment QR" : "ItPay checkout QR";
250
+ const result = await downloadBrandQRToTmp(pngURL, kind, id, {
251
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
252
+ ...(options.baseURL ? { baseURL: options.baseURL } : {}),
253
+ caption,
254
+ });
255
+ if (result.ok && result.attach) {
256
+ plan.ideImageAttach = result.attach;
257
+ return;
258
+ }
259
+ plan.ideImageAttach = {
260
+ localPath: "",
261
+ mirrors: [],
262
+ mimeType: "image/png",
263
+ source: pngURL,
264
+ status: "failed",
265
+ mustRenderReason: IDE_ATTACH_REASON,
266
+ ...(result.reason ? { error: result.reason } : {}),
267
+ };
268
+ }
269
+ // The mandatory trailing block every renderer must append when an
270
+ // ideImageAttach is present. Renderers should call this and emit the
271
+ // returned lines verbatim — wording is part of the contract. The image stays
272
+ // at the owner-only local path so the host adapter can attach it without
273
+ // pushing a large base64 payload through command stdout.
274
+ export function ideImageAttachBlock(attach) {
275
+ const lines = [];
276
+ // Public-path note (some IDEs read this with their file panel).
277
+ if (attach.status === "downloaded" && attach.localPath) {
278
+ lines.push("");
279
+ lines.push("> [ATTACH] IDE image (must render)");
280
+ lines.push(`> canonical: \`${attach.localPath}\``);
281
+ if (attach.mirrors.length > 0) {
282
+ lines.push(`> mirrors:`);
283
+ for (const mirror of attach.mirrors) {
284
+ lines.push(`> - \`${mirror}\``);
285
+ }
286
+ }
287
+ }
288
+ else if (attach.status === "failed") {
289
+ lines.push("");
290
+ lines.push("> [ATTACH] IDE image MISSING — render is required but the brand image could not be downloaded");
291
+ if (attach.error)
292
+ lines.push(`> error: ${attach.error}`);
293
+ }
294
+ else if (attach.status === "disabled") {
295
+ lines.push("");
296
+ lines.push("> [ATTACH] IDE image attach disabled (ITPAY_IDE_IMAGE_ATTACH=0)");
297
+ }
298
+ else {
299
+ lines.push("");
300
+ lines.push("> [ATTACH] IDE image (must render)");
301
+ }
302
+ lines.push(`> source: ${attach.source}`);
303
+ lines.push(`> mime: ${attach.mimeType}`);
304
+ if (attach.caption)
305
+ lines.push(`> caption: ${attach.caption}`);
306
+ lines.push(`> status: ${attach.status}`);
307
+ lines.push(`> rule: ${attach.mustRenderReason}`);
308
+ lines.push("> action: agent must read the canonical local path (or any mirror) " +
309
+ "into the IDE chat window and wait for the human to confirm the image is visible. " +
310
+ "If the image does not appear in chat, the checkout is considered failed.");
311
+ return lines;
312
+ }
313
+ export function readFileAsDataURL(filePath, mimeType) {
314
+ try {
315
+ const buf = readFileSync(filePath);
316
+ return `data:${mimeType};base64,${buf.toString("base64")}`;
317
+ }
318
+ catch {
319
+ return undefined;
320
+ }
321
+ }
@@ -0,0 +1,57 @@
1
+ // Render plan entry point. `dispatch` runs the right renderer based
2
+ // on the host. Every render path goes through `ensureIdeImageAttach`
3
+ // so the brand QR gets downloaded to /tmp and the IDE attach is in
4
+ // place before any renderer (or the JSON output) is produced.
5
+ import { renderTerminal } from "./terminal.js";
6
+ import { renderMarkdown } from "./markdown.js";
7
+ import { renderPlainChat } from "./plain_chat.js";
8
+ import { renderTelegram } from "./telegram.js";
9
+ import { renderFeishu } from "./feishu.js";
10
+ import { platformKeyForHost } from "./plan.js";
11
+ import { ensureIdeImageAttach } from "./ide.js";
12
+ export async function dispatchRender(plan, options) {
13
+ await ensureIdeImageAttach(plan, options);
14
+ const key = platformKeyForHost(plan.host);
15
+ switch (key) {
16
+ case "terminal": {
17
+ const terminalOptions = {
18
+ format: options.qrFormat ?? "terminal",
19
+ isTTY: options.isTTY ?? Boolean(process.stdout.isTTY),
20
+ ...(options.asciiWidth ? { asciiWidth: options.asciiWidth } : {}),
21
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
22
+ ...(options.qrFilePath ? { qrFilePath: options.qrFilePath } : {}),
23
+ ...(options.output ? { output: options.output } : {}),
24
+ ...(options.baseURL ? { baseURL: options.baseURL } : {}),
25
+ };
26
+ await renderTerminal(plan, terminalOptions);
27
+ return;
28
+ }
29
+ case "markdown":
30
+ renderMarkdown(plan, options.output ? { output: options.output } : {});
31
+ return;
32
+ case "telegram":
33
+ if (!options.target) {
34
+ throw new Error(`--target is required for host ${plan.host}`);
35
+ }
36
+ renderTelegram(plan, {
37
+ target: options.target,
38
+ ...(options.output ? { output: options.output } : {}),
39
+ });
40
+ return;
41
+ case "feishu":
42
+ case "lark":
43
+ if (!options.target) {
44
+ throw new Error(`--target is required for host ${plan.host}`);
45
+ }
46
+ renderFeishu(plan, {
47
+ target: options.target,
48
+ host: key,
49
+ ...(options.output ? { output: options.output } : {}),
50
+ });
51
+ return;
52
+ case "plain_chat":
53
+ default:
54
+ renderPlainChat(plan, options.output ? { output: options.output } : {});
55
+ return;
56
+ }
57
+ }
@@ -0,0 +1,49 @@
1
+ import { platformKeyForHost } from "./plan.js";
2
+ import { renderTerminalInteraction } from "./terminal.js";
3
+ import { renderInteractionMarkdown } from "./markdown.js";
4
+ import { renderPlainChatInteraction } from "./plain_chat.js";
5
+ import { renderTelegramInteraction } from "./telegram.js";
6
+ import { renderFeishuInteraction } from "./feishu.js";
7
+ export async function dispatchInteractionRequest(host, request, options = {}) {
8
+ const key = platformKeyForHost(host);
9
+ switch (key) {
10
+ case "terminal":
11
+ await renderTerminalInteraction(request, {
12
+ isTTY: options.isTTY ?? Boolean(process.stdout.isTTY),
13
+ ...(options.asciiWidth ? { asciiWidth: options.asciiWidth } : {}),
14
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
15
+ ...(options.output ? { output: options.output } : {}),
16
+ });
17
+ return;
18
+ case "markdown": {
19
+ const md = renderInteractionMarkdown(request);
20
+ const out = options.output ?? ((line) => process.stdout.write(line + "\n"));
21
+ out(md);
22
+ return;
23
+ }
24
+ case "telegram":
25
+ if (!options.target) {
26
+ throw new Error(`--target is required for host ${host}`);
27
+ }
28
+ renderTelegramInteraction(request, {
29
+ target: options.target,
30
+ ...(options.output ? { output: options.output } : {}),
31
+ });
32
+ return;
33
+ case "feishu":
34
+ case "lark":
35
+ if (!options.target) {
36
+ throw new Error(`--target is required for host ${host}`);
37
+ }
38
+ renderFeishuInteraction(request, {
39
+ target: options.target,
40
+ host: key,
41
+ ...(options.output ? { output: options.output } : {}),
42
+ });
43
+ return;
44
+ case "plain_chat":
45
+ default:
46
+ renderPlainChatInteraction(request, options.output ? { output: options.output } : {});
47
+ return;
48
+ }
49
+ }