@itpay/cli 2.0.2 → 2.0.5

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 (70) hide show
  1. package/README.md +92 -142
  2. package/dist/src/client/backend.js +27 -9
  3. package/dist/src/commands/buy.js +84 -132
  4. package/dist/src/commands/cart.js +274 -169
  5. package/dist/src/commands/catalog.js +63 -38
  6. package/dist/src/commands/checkout.js +117 -78
  7. package/dist/src/commands/docs.js +97 -51
  8. package/dist/src/commands/guidance.js +87 -15
  9. package/dist/src/commands/install.js +50 -87
  10. package/dist/src/commands/next.js +45 -0
  11. package/dist/src/commands/order.js +44 -69
  12. package/dist/src/commands/orders.js +43 -15
  13. package/dist/src/commands/pay.js +51 -22
  14. package/dist/src/commands/readyz.js +8 -4
  15. package/dist/src/commands/refund.js +132 -14
  16. package/dist/src/commands/services.js +719 -148
  17. package/dist/src/main.js +711 -201
  18. package/dist/src/render/output.js +2 -3
  19. package/dist/src/state/cart_session.js +13 -17
  20. package/dist/src/state/client_context.js +4 -2
  21. package/dist/src/state/config.js +3 -5
  22. package/dist/src/state/device_authority.js +1 -1
  23. package/docs/agent/buyer/cart-checkout.json +27 -83
  24. package/docs/agent/buyer/install-and-setup.json +23 -67
  25. package/docs/agent/buyer/orders-refunds.json +31 -53
  26. package/docs/agent/buyer/payment-flow.json +24 -57
  27. package/docs/agent/buyer/quickstart.json +39 -162
  28. package/docs/agent/buyer/render-hosts.json +43 -57
  29. package/docs/cli-reference/agent-types.md +45 -0
  30. package/docs/cli-reference/commands/buy.md +167 -0
  31. package/docs/cli-reference/commands/cart/add.md +86 -0
  32. package/docs/cli-reference/commands/cart/clear.md +53 -0
  33. package/docs/cli-reference/commands/cart/index.md +30 -0
  34. package/docs/cli-reference/commands/cart/next.md +71 -0
  35. package/docs/cli-reference/commands/cart/remove.md +53 -0
  36. package/docs/cli-reference/commands/cart/show.md +65 -0
  37. package/docs/cli-reference/commands/catalog/index.md +26 -0
  38. package/docs/cli-reference/commands/catalog/list.md +45 -0
  39. package/docs/cli-reference/commands/checkout.md +74 -0
  40. package/docs/cli-reference/commands/docs/index.md +28 -0
  41. package/docs/cli-reference/commands/docs/list.md +51 -0
  42. package/docs/cli-reference/commands/docs/search.md +69 -0
  43. package/docs/cli-reference/commands/docs/show.md +68 -0
  44. package/docs/cli-reference/commands/install.md +112 -0
  45. package/docs/cli-reference/commands/next.md +87 -0
  46. package/docs/cli-reference/commands/order.md +92 -0
  47. package/docs/cli-reference/commands/orders.md +83 -0
  48. package/docs/cli-reference/commands/pay.md +103 -0
  49. package/docs/cli-reference/commands/readyz.md +39 -0
  50. package/docs/cli-reference/commands/refund/cancel.md +62 -0
  51. package/docs/cli-reference/commands/refund/create.md +85 -0
  52. package/docs/cli-reference/commands/refund/get.md +60 -0
  53. package/docs/cli-reference/commands/refund/index.md +33 -0
  54. package/docs/cli-reference/commands/refund/list.md +68 -0
  55. package/docs/cli-reference/commands/refund/watch.md +73 -0
  56. package/docs/cli-reference/commands/services/action.md +43 -0
  57. package/docs/cli-reference/commands/services/checkout.md +82 -0
  58. package/docs/cli-reference/commands/services/events.md +73 -0
  59. package/docs/cli-reference/commands/services/get.md +66 -0
  60. package/docs/cli-reference/commands/services/index.md +45 -0
  61. package/docs/cli-reference/commands/services/invoke.md +67 -0
  62. package/docs/cli-reference/commands/services/list.md +61 -0
  63. package/docs/cli-reference/commands/services/next.md +162 -0
  64. package/docs/cli-reference/commands/services/quote.md +59 -0
  65. package/docs/cli-reference/commands/services/read-result.md +98 -0
  66. package/docs/cli-reference/commands/services/start.md +53 -0
  67. package/docs/cli-reference/conventions.md +94 -0
  68. package/docs/cli-reference/index.md +64 -0
  69. package/package.json +1 -1
  70. package/skills/itpay-buyer/SKILL.md +47 -113
package/README.md CHANGED
@@ -1,152 +1,102 @@
1
1
  # ItPay CLI
2
2
 
3
- The V3 terminal and agent-facing CLI for ItPay commerce.
3
+ The official V3 CLI for Agent-driven ItPay service discovery, checkout, delivery, order recovery, and refunds.
4
4
 
5
5
  ```bash
6
6
  npm install -g @itpay/cli
7
- itpay readyz
8
- itpay docs show quickstart
7
+ itpay install --json
8
+ itpay --agent-type codex-desktop readyz --json
9
+ itpay --agent-type codex-desktop catalog list --json
9
10
  ```
10
11
 
11
- The CLI defaults to the production API at `https://api.itpay.ai`. Set
12
- `ITPAY_BACKEND_URL` only when intentionally using another backend.
13
-
14
- ## Commands
15
-
16
- - `itpay readyz` — `GET /v1/readyz`
17
- - `itpay next [--json]` — show the next recommended agent action from remembered server handles
18
- - `itpay cart add --item <id> --variant <id> --offer <id> [--quantity 1] [--input <json>] [--host <host>] [--json]` — create/update the canonical server cart; service-backed lines return `service_execution_id`
19
- - `itpay cart next [--json]` — show the next recommended action for the remembered canonical server cart
20
- - `itpay cart add --local ...` — explicit local draft compatibility mode only, not valid for service-backed flows
21
- - `itpay cart remove --line <cart_item_id>` — soft-remove an active line from the canonical server cart; quote-locked or checkout-bound lines are rejected
22
- - `itpay cart remove --local --variant <id> --offer <id>` — drop a local draft line
23
- - `itpay cart show` — print the canonical server cart, or local draft fallback when no server cart handle exists
24
- - `itpay cart clear` — abandon the canonical server cart and clear local handles
25
- - `itpay cart clear --local` — clear local handles/draft only
26
- - `itpay buy --cart <cart_id> --host <host> [--target <target>] [--qr-format ...] [--qr-file <path>]` create checkout from a canonical server cart and render the branded checkout QR for the host
27
- - `itpay buy --host <host> [--target <target>] [--item ... --variant ... --offer ...] [--quantity 1]` — compatibility one-shot cart + checkout path
28
- - `itpay services start <service_id>` — start a generic Service Execution run
29
- - `itpay services invoke <service_execution_id> --capability <capability_id> --input key=value` — invoke an agent-visible capability
30
- - `itpay services action <service_execution_id> --action <action_type> [--result-item <id>]` — record a human/agent service action
31
- - `itpay services checkout <service_execution_id> --capability <capability_id> [--email <email>] [--host <host>] [--json]` — create quote lock from Service Execution state, require email only for capabilities that deliver a claim link, persist the handoff, and render the branded ItPay checkout
32
- - `itpay services checkout <service_execution_id> --resume --json` — reissue a lost or expired handoff for the same unpaid checkout without asking for contact information again
33
- - `itpay services next <service_execution_id> [--json]` — show the next recommended action from the Service Execution read model
34
- - `itpay services get <service_execution_id>` / `itpay services events <service_execution_id>` — read the redacted Service Execution timeline
35
- - `itpay checkout --id <checkout_id> --token <display_token>` — read canonical checkout presentation
36
- - `itpay pay --checkout <id> --method alipay|wechatpay` — CLI escape hatch for operator/manual testing only; normal buyer flow opens the ItPay checkout page first
37
- - `itpay order <order_id>` — read one V3 order
38
- - `itpay orders [--limit 20] [--status <status>]` — list account-scoped orders (requires `ITPAY_BEARER_TOKEN`)
39
- - `itpay refund --order <id> --payment-intent <id> --amount-minor <n> --currency <code>` — request a refund
40
-
41
- ## Hosts
42
-
43
- The CLI dispatches to a per-host renderer based on `--host`:
44
-
45
- | `--host` | Renderer | Native UI |
46
- | --- | --- | --- |
47
- | `terminal` | `render/terminal.ts` | terminal QR + summary |
48
- | `codex`, `claude-code` | `render/markdown.ts` | markdown image + links |
49
- | `telegram` | `render/telegram.ts` | openclaw `message send` with inline buttons |
50
- | `feishu`, `lark` | `render/feishu.ts` | Feishu/Lark interactive card (url + callback) |
51
- | `discord`, `whatsapp`, `plain-chat` | `render/plain_chat.ts` | text + links, no native buttons |
52
-
53
- Aliases: `tg` and `openclaw-telegram` map to `telegram`; `feishu_im` and `fs` map to `feishu`.
12
+ The default API is `https://app.itpay.ai`. Set `ITPAY_BACKEND_URL` only for an intentional test or local environment.
13
+
14
+ ## Output Contract
15
+
16
+ Normal JSON commands return one bounded envelope:
17
+
18
+ ```text
19
+ status current command state
20
+ result facts needed at this step
21
+ handoff optional human-visible URL/image fields for the current Host
22
+ instruction how the Agent must use the result
23
+ next zero or one preferred executable command
24
+ recovery exceptional recovery commands only
25
+ ```
26
+
27
+ Run `next.command` unchanged after filling only explicit placeholders or user-provided required fields. Do not inspect raw APIs or hardcode a service workflow.
28
+
29
+ Normative per-command contracts: [CLI Command Reference](docs/cli-reference/index.md).
30
+
31
+ ## Supported Agent Types
32
+
33
+ | Agent Type | Default Host |
34
+ | --- | --- |
35
+ | `codex-desktop` | `codex` |
36
+ | `codex-cli` | `terminal` |
37
+ | `claude-code-desktop` | `claude-code` |
38
+ | `claude-code-cli` | `terminal` |
39
+ | `workbuddy` | `plain-chat` |
40
+
41
+ `--agent-type` identifies the stable runtime and registered Agent instance. `--host` only selects the human presentation surface. Use `itpay install <agent_type> --json` for the exact responsibility.
42
+
43
+ ## Command Families
44
+
45
+ - `readyz`, `catalog list`: compatibility and discovery.
46
+ - `services start/invoke/action/checkout/next`: generic Service Execution flow.
47
+ - `cart add/show/remove/clear/next`, `buy`: canonical Cart and ordinary Checkout flow.
48
+ - `checkout`: authoritative payment and fulfillment recovery.
49
+ - `services read-result`: read one human-granted protected result.
50
+ - `order`, `orders`: exact order and account order views.
51
+ - `refund create/list/get/watch/cancel`: Refund Owner flow.
52
+ - `services get/events`: redacted support diagnostics; normal flows should use `services next`.
53
+ - `install`, `docs list/show/search`: offline packaged guidance.
54
+ - `pay`: operator escape hatch only; normal buyers use the ItPay Checkout page.
55
+
56
+ Run `itpay <command> --help` or browse [the command index](docs/cli-reference/index.md) for parameters.
57
+
58
+ ## Recovery
59
+
60
+ Use server-backed recovery before creating another resource:
61
+
62
+ ```bash
63
+ itpay --agent-type <agent_type> next --json
64
+ itpay --agent-type <agent_type> services list --json
65
+ itpay --agent-type <agent_type> services next <service_execution_id> --json
66
+ itpay --agent-type <agent_type> services checkout <service_execution_id> --resume --json
67
+ itpay checkout --id <checkout_id> --token <display_token> --json
68
+ ```
69
+
70
+ The local `~/.itpay-v3` directory stores the signed Device Authority, Agent instances, idempotency operations, and recovery handles. Backend state remains authoritative.
54
71
 
55
72
  ## Environment
56
73
 
57
- - `ITPAY_BACKEND_URL` optional backend override (default `https://api.itpay.ai`)
58
- - `ITPAY_BEARER_TOKEN` account-scoped session token (only needed for `orders`)
59
- - `ITPAY_AGENT_DEVICE_ID` agent device id, used for cart/service execution quota identity and `client_context`
60
- - `ITPAY_CURRENCY` checkout currency (default `CNY`)
61
- - `ITPAY_IDEMPOTENCY_KEY` `Idempotency-Key` for pay/refund requests (auto-generated if unset)
62
- - `ITPAY_IDE_IMAGE_ATTACH` set to `0` to disable the IDE image-attach contract (e.g. read-only runner FS). Default `1`.
63
- - `ITPAY_IDE_IMAGE_DIR_OVERRIDE` override the canonical IDE image directory instead of `$TMPDIR/itpay-v3-qr`. Useful when the IDE file panel only knows one path.
64
-
65
- ## Agent next actions
66
-
67
- Service-backed flows return progressive guidance for agents:
68
-
69
- - `itpay cart add --json`, `itpay services start`, `itpay services invoke`,
70
- `itpay services action`, `itpay services get`, and
71
- `itpay services checkout --json` include top-level `next_actions`.
72
- - `itpay next`, `itpay cart next`, and `itpay services next <id>` print only
73
- the next recommended command and recovery commands.
74
- - The guidance is derived from server cart and Service Execution read models.
75
- Local `~/.itpay-v3/cart.json` only stores handles such as `cart_id`,
76
- `service_execution_id`, `checkout_id`, and `display_token`.
77
-
78
- Agents should prefer `next_actions` over hardcoded service-specific flows.
79
- For example, a service-backed cart add can return an invoke command for an
80
- agent-visible free capability, while a quote-locked execution can return the
81
- checkout handoff command.
82
-
83
- ## IDE image attach
84
-
85
- Every `itpay buy`, `itpay services checkout` (and `itpay checkout`,
86
- `itpay order`) downloads the
87
- brand checkout QR from the backend and writes it to a stable local
88
- file the agent can hand to the IDE image viewer (Trae `Read` tool,
89
- Codex, Claude Code). The contract is:
90
-
91
- - canonical file: `<os.tmpdir()>/itpay-v3-qr/itpay-v3-<kind>-<id>.png`
92
- (override with `ITPAY_IDE_IMAGE_DIR_OVERRIDE`)
93
- - when `/tmp/itpay-v3-qr` is a separate, writable location it gets a
94
- mirror of the same file under the same name
95
- - filename is stable per checkout, so re-runs overwrite the same
96
- local file rather than scattering copies across the scratch dir
97
-
98
- Outputs that carry the IDE image attach:
99
-
100
- - `itpay buy --json` — fields `brand_qr_local_path`, `brand_qr_mirrors`,
101
- `brand_qr_stable_name`, `brand_qr_status` (`downloaded` / `failed`
102
- / `disabled` / `fallback`), `brand_qr_error`, `brand_qr_data_url`,
103
- `brand_qr_must_render_reason`, `brand_qr_render_action`. Read the
104
- path with the IDE's `Read` tool so the human sees the picture.
105
- - `itpay services checkout --json` — same brand QR fields, plus
106
- `next_action: "open_human_checkout"` and the checkout-scoped
107
- `display_token`. Agents must show this ItPay checkout QR/URL to the
108
- human and must not call `itpay pay` for the normal buyer flow.
109
- - Markdown (Trae / Codex / Claude Code) — inlines a `data:image/png;base64,...`
110
- copy of the picture plus a `[ATTACH] IDE image` reference block that
111
- points at the canonical local path and mirrors.
112
- - Terminal — prints `Branded QR: /path/...png` and `QR mirrors: ...`;
113
- iTerm inline image renders the same file when the session supports it.
114
- - Telegram — the `ide_image_attach` block on `presentation.ide_image_attach`
115
- carries `status`, `local_path`, `mirrors`, `mime_type`, `source`,
116
- `caption`, `error` (when failed), `must_render_reason`, and a
117
- step-by-step `instructions` array.
118
- - Feishu / Lark — same `ide_image_attach` block on the
119
- `message.ide_image_attach` envelope.
120
-
121
- Disable the contract with `ITPAY_IDE_IMAGE_ATTACH=0` for runners on a
122
- read-only filesystem. The plan carries `status: "disabled"` instead
123
- of `status: "downloaded"` and no PNG is downloaded.
124
-
125
- ## Layout
126
-
127
- - `src/main.ts` — `commander` entrypoint, command registration
128
- - `src/client/` — HTTP/JSON client and DTOs
129
- - `src/commands/` — one file per command family
130
- - `src/render/` — terminal formatting
131
- - `plan.ts` — `RenderPlan` contract shared by all renderers
132
- - `qr.ts` — local QR + format selection
133
- - `terminal.ts`, `markdown.ts`, `plain_chat.ts`, `telegram.ts`, `feishu.ts` — per-host renderers
134
- - `index.ts` — `dispatchRender()` picks the right renderer
135
- - `sink.ts` — `OutputSink` so tests can silence stdout
136
- - `src/state/` — local CLI config, cart session, client context
137
- - `tests/` — node:test smoke test + in-process mock backend
138
-
139
- ## Rules
140
-
141
- - commands orchestrate user intent only
142
- - render code must not issue HTTP calls
143
- - keep API access under `src/client/`
144
- - persist checkout-scoped `display_token` and last server handles only in the owner-only local cart session file
145
- - the default `buy` command must not create a payment intent unless `--pay` is explicit
146
- - the default `services checkout` command must render the ItPay
147
- checkout handoff; provider payment intents are created by the human
148
- checkout page, not by the agent
149
- - a renderer must consume the brand QR the V3 backend hands back
150
- (`qr_payload` / `qr_png_url` / `mobile_wallet_url`) and only
151
- self-generate a QR for `auth_qr` / `checkout_qr` with the explicit
152
- `--qr-file` opt-in
74
+ - `ITPAY_BACKEND_URL`: deliberate API override.
75
+ - `ITPAY_AGENT_TYPE`: stable alternative to global `--agent-type`.
76
+ - `ITPAY_BEARER_TOKEN`: account-scoped Buyer session for account-only commands such as `orders`.
77
+ - `ITPAY_CART_SESSION_PATH`: local recovery-state path override.
78
+ - `ITPAY_CURRENCY`: ordinary Cart currency, default `CNY`.
79
+ - `ITPAY_IDEMPOTENCY_KEY`: explicit operation key for deterministic testing; normal use persists operation IDs automatically.
80
+ - `ITPAY_IDE_IMAGE_ATTACH=0`: disable local Checkout image download when the runtime filesystem is read-only.
81
+ - `ITPAY_IDE_IMAGE_DIR_OVERRIDE`: override the local Checkout image directory.
82
+
83
+ Provider credentials, Buyer identity, payment provider choice, amount, refund policy, quota, grant scope, and delivery access are never client-owned environment settings.
84
+
85
+ ## Development
86
+
87
+ ```bash
88
+ npm run lint
89
+ npm test
90
+ npm run test:package
91
+ npm run pack:dry-run
92
+ ```
93
+
94
+ Source boundaries:
95
+
96
+ - `src/main.ts`: parser and error-envelope wiring.
97
+ - `src/client`: typed HTTP access.
98
+ - `src/commands`: command orchestration and public output projection.
99
+ - `src/render`: Host presentation only; no business HTTP.
100
+ - `src/state`: local Device Authority, idempotency journal, and recovery handles.
101
+ - `docs/cli-reference`: normative command contracts.
102
+ - `docs/agent/buyer`: packaged progressive workflow guidance.
@@ -32,17 +32,16 @@ export class BackendClient {
32
32
  return this.http.delete(`/v1/carts/${encodeURIComponent(cartID)}`);
33
33
  }
34
34
  // --- Checkout ---
35
- createCheckout(input) {
36
- return this.http.post("/v1/checkouts", input);
35
+ createCheckout(input, idempotencyKey) {
36
+ return this.http.post("/v1/checkouts", input, idempotencyKey ? { idempotencyKey } : undefined);
37
37
  }
38
38
  getCheckoutPresentation(checkoutID, displayToken) {
39
39
  const qs = new URLSearchParams({ display_token: displayToken });
40
40
  return this.http.get(`/v1/checkouts/${encodeURIComponent(checkoutID)}/presentation?${qs}`);
41
41
  }
42
42
  // --- Payment intents ---
43
- createPaymentIntent(checkoutID, input, idempotencyKey) {
44
- const options = idempotencyKey ? { idempotencyKey } : {};
45
- return this.http.post(`/v1/checkouts/${encodeURIComponent(checkoutID)}/payment-intents`, input, options);
43
+ createPaymentIntent(checkoutID, input) {
44
+ return this.http.post(`/v1/checkouts/${encodeURIComponent(checkoutID)}/payment-intents`, input);
46
45
  }
47
46
  // --- SSE streaming ---
48
47
  streamCheckoutEvents(checkoutID, displayToken, onEvent, signal) {
@@ -54,6 +53,9 @@ export class BackendClient {
54
53
  getOrder(orderID) {
55
54
  return this.http.get(`/v1/orders/${encodeURIComponent(orderID)}`);
56
55
  }
56
+ getOrderDeliveryAccess(orderID) {
57
+ return this.http.get(`/v1/orders/${encodeURIComponent(orderID)}/delivery-access`);
58
+ }
57
59
  listAccountOrders(limit, status, bearer) {
58
60
  const qs = new URLSearchParams({ limit: String(limit) });
59
61
  if (status) {
@@ -62,10 +64,19 @@ export class BackendClient {
62
64
  return this.http.get(`/v1/me/orders?${qs}`, bearer ? { bearer } : {});
63
65
  }
64
66
  // --- Refund ---
65
- createRefund(orderID, input, idempotencyKey) {
66
- const options = idempotencyKey ? { idempotencyKey } : {};
67
+ createRefund(orderID, input, bearer, idempotencyKey) {
68
+ const options = { ...(bearer ? { bearer } : {}), ...(idempotencyKey ? { idempotencyKey } : {}) };
67
69
  return this.http.post(`/v1/orders/${encodeURIComponent(orderID)}/refunds`, input, options);
68
70
  }
71
+ listOrderRefunds(orderID) {
72
+ return this.http.get(`/v1/orders/${encodeURIComponent(orderID)}/refunds`);
73
+ }
74
+ getRefund(refundRequestID) {
75
+ return this.http.get(`/v1/refunds/${encodeURIComponent(refundRequestID)}`);
76
+ }
77
+ cancelRefund(refundRequestID, reason = "buyer_cancelled") {
78
+ return this.http.post(`/v1/refunds/${encodeURIComponent(refundRequestID)}/cancel`, { reason });
79
+ }
69
80
  // --- Service Execution ---
70
81
  startServiceExecution(input) {
71
82
  return this.http.post("/v1/service-executions", input);
@@ -79,14 +90,21 @@ export class BackendClient {
79
90
  createServiceExecutionCheckout(serviceExecutionID, input) {
80
91
  return this.http.post(`/v1/service-executions/${encodeURIComponent(serviceExecutionID)}/checkout`, input);
81
92
  }
93
+ prepareServiceQuote(serviceExecutionID, input) {
94
+ return this.http.post(`/v1/service-executions/${encodeURIComponent(serviceExecutionID)}/quotes`, input);
95
+ }
82
96
  getServiceExecution(serviceExecutionID) {
83
97
  return this.http.get(`/v1/service-executions/${encodeURIComponent(serviceExecutionID)}`);
84
98
  }
85
99
  listServiceExecutions(limit = 50) {
86
100
  return this.http.get(`/v1/service-executions?limit=${limit}`);
87
101
  }
88
- listServiceExecutionEvents(serviceExecutionID) {
89
- return this.http.get(`/v1/service-executions/${encodeURIComponent(serviceExecutionID)}/events`);
102
+ listServiceExecutionEvents(serviceExecutionID, afterSequence = 0, limit = 50) {
103
+ const query = new URLSearchParams({
104
+ after_sequence: String(afterSequence),
105
+ limit: String(limit),
106
+ });
107
+ return this.http.get(`/v1/service-executions/${encodeURIComponent(serviceExecutionID)}/events?${query}`);
90
108
  }
91
109
  getGrantedServiceResult(serviceExecutionID) {
92
110
  return this.http.get(`/v1/service-executions/${encodeURIComponent(serviceExecutionID)}/granted-result`);
@@ -11,54 +11,55 @@
11
11
  import { operationID } from "../state/config.js";
12
12
  import { validateContext } from "../state/client_context.js";
13
13
  import { dispatchRender } from "../render/index.js";
14
- import { dispatchInteractionRequest } from "../render/interaction.js";
15
- import { ensureIdeImageAttach, readFileAsDataURL } from "../render/ide.js";
14
+ import { platformKeyForHost } from "../render/plan.js";
15
+ import { ensureIdeImageAttach } from "../render/ide.js";
16
16
  import { buildAgentChatHandoff } from "../render/markdown.js";
17
+ import { formatMoney } from "../render/output.js";
18
+ import { CommandContractError, writeCommandEnvelope } from "./guidance.js";
17
19
  export async function runBuy(backend, config, options) {
18
20
  const err = validateContext(options.host, options.target);
19
21
  if (err) {
20
22
  throw new Error(`${err.code}: ${err.message}`);
21
23
  }
22
24
  const snap = options.cartSession.show();
23
- if (!options.cartID && snap.items.length === 0) {
24
- throw new Error("cart is empty; add an item with `itpay cart add` first");
25
+ const resumableCartID = !options.cartID && snap.items.length === 0 ? snap.lastCartID : undefined;
26
+ if (!options.cartID && !resumableCartID && snap.items.length === 0) {
27
+ throw new CommandContractError("cart_empty", "no local draft or canonical cart is available", "没有可购买的普通 Cart;从已发布目录选择项目,不要猜测 item、variant 或 offer。", [{ command: "itpay catalog list --json", reason: "读取已发布项目" }]);
25
28
  }
26
29
  const missingContactFields = findMissingContactFields(options.contact, options.requiredContactFields ?? []);
27
30
  if (missingContactFields.length > 0) {
28
- const interactionRequest = buildMissingContactInteractionRequest(missingContactFields);
29
- await dispatchInteractionRequest(options.host, interactionRequest, {
30
- ...(options.isTTY !== undefined ? { isTTY: options.isTTY } : {}),
31
- ...(options.target ? { target: options.target } : {}),
32
- ...(options.output ? { output: options.output } : {}),
33
- });
34
- return {
35
- kind: "interaction_requested",
36
- interactionRequest,
37
- };
31
+ throw new CommandContractError("missing_contact", `missing required contact fields: ${missingContactFields.join(", ")}`, `向用户询问 ${missingContactFields.join(" 和 ")},然后在同一 buy 命令补充对应 contact 参数;禁止编造。`, [{ command: "itpay buy --help", reason: "查看 contact 参数" }]);
38
32
  }
39
33
  let cart;
40
- if (options.cartID) {
41
- cart = await backend.getCart(options.cartID);
34
+ if (options.cartID || resumableCartID) {
35
+ cart = await backend.getCart(options.cartID ?? resumableCartID);
42
36
  }
43
37
  else {
44
38
  const request = options.cartSession.toCreateCartRequest();
45
39
  request.client_context = {
46
40
  host: options.host,
47
41
  target: options.target,
48
- agent_device_id: options.cartSession.ensureAgentDeviceID(config.agentDeviceID),
49
42
  };
50
- const agentDeviceID = options.cartSession.ensureAgentDeviceID(config.agentDeviceID);
51
- request.agent_device_id = agentDeviceID;
52
43
  cart = await backend.createCart(request);
53
44
  }
45
+ const latestLine = cart.items[cart.items.length - 1];
46
+ options.cartSession.rememberServerCart({
47
+ cartID: cart.cart_id,
48
+ ...(latestLine?.cart_item_id ? { cartItemID: latestLine.cart_item_id } : {}),
49
+ ...(latestLine?.service_execution_id ? { serviceExecutionID: latestLine.service_execution_id } : {}),
50
+ });
51
+ const unquotedServiceLine = cart.items.find((item) => item.service_execution_id && !item.service_quote_lock_id);
52
+ if (unquotedServiceLine?.service_execution_id) {
53
+ throw new CommandContractError("service_quote_required", `cart ${cart.cart_id} contains an unquoted service-backed line`, "该服务项目尚未绑定 Quote Lock;回到来源 Execution 完成候选选择和报价。", [{ command: `itpay services next ${unquotedServiceLine.service_execution_id} --json`, reason: "读取当前合法动作" }]);
54
+ }
55
+ const idempotencyKey = await operationID(config, `checkout.create:${cart.cart_id}`);
54
56
  const checkoutRequest = {
55
57
  cart_id: cart.cart_id,
56
- client_reference_id: options.clientReferenceID ?? await operationID(config, `checkout.create:${cart.cart_id}`),
58
+ client_reference_id: options.clientReferenceID ?? idempotencyKey,
57
59
  ...(options.contact ? { delivery_contact: options.contact } : {}),
58
60
  };
59
- const checkout = await backend.createCheckout(checkoutRequest);
61
+ const checkout = await backend.createCheckout(checkoutRequest, idempotencyKey);
60
62
  options.cartSession.rememberCheckout({
61
- cartID: cart.cart_id,
62
63
  checkoutID: checkout.checkout.checkout_id,
63
64
  displayToken: checkout.display_token,
64
65
  checkoutURL: tokenizedCheckoutURL(checkout.checkout_url, checkout.display_token, checkout.qr_payload),
@@ -77,7 +78,7 @@ export async function runBuy(backend, config, options) {
77
78
  let waitStatus = "skipped";
78
79
  if (options.pay) {
79
80
  const method = options.payMethod ?? "alipay";
80
- paymentIntent = await backend.createPaymentIntent(checkoutID, { payment_method_type: method, display_token: displayToken }, await operationID(config, `payment.intent:${checkoutID}:${method}`));
81
+ paymentIntent = await backend.createPaymentIntent(checkoutID, { payment_method_type: method, display_token: displayToken });
81
82
  if (!options.jsonOutput) {
82
83
  process.stdout.write("\n--- payment intent ---\n");
83
84
  process.stdout.write(` id: ${paymentIntent.payment_intent_id}\n`);
@@ -124,34 +125,23 @@ export async function runBuy(backend, config, options) {
124
125
  });
125
126
  // --- Output ---
126
127
  if (options.jsonOutput) {
127
- const jsonInput = {
128
- checkout: { ...checkout, checkout_url: checkoutURL },
128
+ const envelope = buildBuyEnvelope({
129
129
  cart,
130
- waitStatus,
130
+ checkoutID,
131
+ checkoutURL,
132
+ displayToken,
131
133
  plan,
132
- };
133
- if (paymentIntent) {
134
- jsonInput.paymentIntent = paymentIntent;
135
- }
136
- if (plan.ideImageAttach) {
137
- jsonInput.ideImageAttach = {
138
- localPath: plan.ideImageAttach.localPath,
139
- mirrors: plan.ideImageAttach.mirrors,
140
- mimeType: plan.ideImageAttach.mimeType,
141
- ...(plan.ideImageAttach.caption ? { caption: plan.ideImageAttach.caption } : {}),
142
- mustRenderReason: plan.ideImageAttach.mustRenderReason,
143
- status: plan.ideImageAttach.status,
144
- ...(plan.ideImageAttach.error ? { error: plan.ideImageAttach.error } : {}),
145
- };
146
- }
147
- const json = buildJSONOutput(jsonInput);
148
- (options.output ?? ((line) => process.stdout.write(line + "\n")))(JSON.stringify(json, null, 2) + "\n");
134
+ waitStatus,
135
+ ...(checkout.qr_png_url ? { qrPNGURL: checkout.qr_png_url } : {}),
136
+ ...(paymentIntent ? { paymentIntent } : {}),
137
+ });
138
+ writeCommandEnvelope(envelope, { jsonOutput: true, ...(options.output ? { output: options.output } : {}) });
149
139
  return {
150
140
  kind: "checkout_rendered",
151
141
  plan,
152
142
  checkoutID,
153
143
  displayToken,
154
- json,
144
+ envelope,
155
145
  };
156
146
  }
157
147
  // Text output — always render QR for non-JSON mode
@@ -173,6 +163,55 @@ export async function runBuy(backend, config, options) {
173
163
  displayToken,
174
164
  };
175
165
  }
166
+ function buildBuyEnvelope(input) {
167
+ const verified = input.waitStatus === "verified";
168
+ const platform = platformKeyForHost(input.plan.host);
169
+ const handoff = { url: input.checkoutURL };
170
+ if (input.plan.ideImageAttach?.status === "downloaded" && input.plan.ideImageAttach.localPath) {
171
+ handoff.qr_local_path = input.plan.ideImageAttach.localPath;
172
+ }
173
+ if (platform === "markdown") {
174
+ handoff.markdown = buildAgentChatHandoff(input.plan).markdown;
175
+ }
176
+ else if (platform === "plain_chat" && input.qrPNGURL) {
177
+ handoff.qr_image_url = input.qrPNGURL;
178
+ }
179
+ const result = {
180
+ checkout_id: input.checkoutID,
181
+ payment: verified ? "verified" : "pending",
182
+ amount: formatMoney(input.cart.amount_minor, input.cart.currency),
183
+ item_count: input.cart.items.length,
184
+ ...(input.paymentIntent ? {
185
+ payment_intent_id: input.paymentIntent.payment_intent_id,
186
+ payment_intent_status: input.paymentIntent.status,
187
+ } : {}),
188
+ ...(input.waitStatus === "timeout" ? { wait_status: "timeout" } : {}),
189
+ };
190
+ if (verified) {
191
+ return {
192
+ status: "payment_event_observed",
193
+ result,
194
+ instruction: "已观察到付款确认事件;读取同一 Checkout 的权威完成状态,不要再次付款。",
195
+ next: { command: `itpay checkout --id ${input.checkoutID} --token ${input.displayToken} --json`, reason: "读取订单和履约句柄" },
196
+ recovery: [],
197
+ };
198
+ }
199
+ return {
200
+ status: "human_checkout_required",
201
+ result,
202
+ handoff,
203
+ instruction: buyHandoffInstruction(platform),
204
+ next: { command: `itpay checkout --id ${input.checkoutID} --token ${input.displayToken} --json`, reason: "稍后查询同一笔 Checkout 状态" },
205
+ recovery: [],
206
+ };
207
+ }
208
+ function buyHandoffInstruction(platform) {
209
+ if (platform === "markdown")
210
+ return "把 handoff.markdown 原样发送到当前桌面对话;二维码和链接可见后等待用户操作,不要创建新 Checkout。";
211
+ if (platform === "terminal")
212
+ return "在用户可见终端展示 handoff 中的付款入口,然后等待用户操作;不要创建新 Checkout。";
213
+ return "把 handoff.url 和可用二维码附件发送给用户,然后等待用户操作;不要创建新 Checkout。";
214
+ }
176
215
  // --- SSE wait for payment verification ---
177
216
  async function waitForPaymentSSE(backend, checkoutID, displayToken, timeoutSec) {
178
217
  return new Promise((resolve) => {
@@ -194,81 +233,11 @@ async function waitForPaymentSSE(backend, checkoutID, displayToken, timeoutSec)
194
233
  });
195
234
  });
196
235
  }
197
- export function buildJSONOutput(input) {
198
- const output = {
199
- kind: "checkout_created",
200
- checkout_id: input.checkout.checkout.checkout_id,
201
- checkout_url: input.checkout.checkout_url,
202
- display_token: input.checkout.display_token,
203
- qr_payload: input.checkout.qr_payload,
204
- checkout_status: input.checkout.checkout.status,
205
- wait_status: input.waitStatus,
206
- };
207
- if (input.checkout.qr_png_url) {
208
- output.qr_png_url = input.checkout.qr_png_url;
209
- }
210
- const serviceExecutions = input.cart?.items
211
- .filter((item) => item.service_execution_id)
212
- .map((item) => ({
213
- service_execution_id: item.service_execution_id,
214
- ...(item.service_capability_id ? { service_capability_id: item.service_capability_id } : {}),
215
- ...(item.title ? { title: item.title } : {}),
216
- }));
217
- if (serviceExecutions && serviceExecutions.length > 0) {
218
- output.service_executions = serviceExecutions;
219
- }
220
- if (input.paymentIntent) {
221
- output.payment_intent_id = input.paymentIntent.payment_intent_id;
222
- output.payment_status = input.paymentIntent.status;
223
- if (input.paymentIntent.action) {
224
- const pa = {};
225
- if (input.paymentIntent.action.qr_image_url)
226
- pa.qr_image_url = input.paymentIntent.action.qr_image_url;
227
- if (input.paymentIntent.action.mobile_wallet_url)
228
- pa.mobile_wallet_url = input.paymentIntent.action.mobile_wallet_url;
229
- output.payment_action = pa;
230
- }
231
- output.kind = "payment_handoff_required";
232
- }
233
- if (input.waitStatus === "verified") {
234
- output.kind = "payment_verified";
235
- }
236
- if (input.ideImageAttach) {
237
- output.brand_qr_status = input.ideImageAttach.status;
238
- if (input.ideImageAttach.localPath) {
239
- output.brand_qr_local_path = input.ideImageAttach.localPath;
240
- const dataURL = readFileAsDataURL(input.ideImageAttach.localPath, input.ideImageAttach.mimeType);
241
- if (dataURL)
242
- output.brand_qr_data_url = dataURL;
243
- const stableName = input.ideImageAttach.localPath.split("/").pop();
244
- if (stableName)
245
- output.brand_qr_stable_name = stableName;
246
- }
247
- if (input.ideImageAttach.mirrors.length > 0) {
248
- output.brand_qr_mirrors = [...input.ideImageAttach.mirrors];
249
- }
250
- output.brand_qr_mime_type = input.ideImageAttach.mimeType;
251
- if (input.ideImageAttach.caption)
252
- output.brand_qr_caption = input.ideImageAttach.caption;
253
- if (input.ideImageAttach.error)
254
- output.brand_qr_error = input.ideImageAttach.error;
255
- output.brand_qr_must_render_reason = input.ideImageAttach.mustRenderReason;
256
- output.brand_qr_render_action = "agent_must_read_local_path_into_ide_chat";
257
- }
258
- output.next = input.waitStatus === "verified"
259
- ? "itpay orders"
260
- : `itpay checkout --id ${input.checkout.checkout.checkout_id} --token ${input.checkout.display_token}`;
261
- if (input.plan)
262
- output.agent_action = buildAgentChatHandoff(input.plan);
263
- return output;
264
- }
265
236
  // --- checkout QR plan ---
266
237
  export function buildCheckoutQRPlan(input) {
267
238
  const summary = `Scan the QR or open ${input.checkoutURL} to start the human checkout flow.`;
268
239
  const isPayment = input.paymentIntentID != null;
269
- const afterCommand = isPayment
270
- ? `itpay checkout --id ${input.checkoutID} --token ${input.displayToken}`
271
- : `itpay checkout --id ${input.checkoutID} --token ${input.displayToken}`;
240
+ const afterCommand = `itpay checkout --id ${input.checkoutID} --token ${input.displayToken} --json`;
272
241
  const platform = {
273
242
  text: summary,
274
243
  links: [
@@ -307,23 +276,6 @@ export function buildCheckoutQRPlan(input) {
307
276
  return plan;
308
277
  }
309
278
  // --- contact field interaction ---
310
- export function buildMissingContactInteractionRequest(fields) {
311
- return {
312
- kind: "input",
313
- id: "collect_delivery_contact",
314
- title: "Collect buyer contact",
315
- prompt: "Before creating the checkout, ask the buyer to provide the missing contact details.",
316
- fields: fields.map((field) => ({
317
- id: field,
318
- label: field === "email" ? "Email" : "Phone number",
319
- inputType: field === "email" ? "email" : "phone",
320
- required: true,
321
- placeholder: field === "email" ? "buyer@example.com" : "+86 138...",
322
- description: field === "email" ? "Used for receipts or delivery follow-up." : "Used when the order requires buyer verification.",
323
- })),
324
- submitLabel: "Submit contact",
325
- };
326
- }
327
279
  function findMissingContactFields(contact, fields) {
328
280
  return fields.filter((field) => {
329
281
  const value = contact?.[field];