@gowuai/cli 0.0.0-stage → 0.1.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.
package/README.md CHANGED
@@ -1,3 +1,42 @@
1
- # Temporary Holding Version
1
+ # @gowuai/cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 格物(GoWu)开放平台命令行 `gowu`:搜索商品、下单付款、订单与售后;员工福利 / 企业采购应用还能查企业额度、做企业管理、参与福豆任务。给人用,也给 AI Agent 用(Agent 执行命令、读 Markdown 或 `--json` 输出)。
4
+
5
+ 需要 Node.js 18.17 及以上。
6
+
7
+ ## 安装与登录
8
+
9
+ ```bash
10
+ npx -y @gowuai/cli login # 免安装运行
11
+ # 或全局安装后用 gowu
12
+ npm i -g @gowuai/cli
13
+ gowu login
14
+ ```
15
+
16
+ `gowu login` 会自动打开浏览器授权页,用手机号登录、选择要授权的应用即可;同时打印网址和授权码,没弹出浏览器(远程服务器、SSH、Agent 沙箱)时手动打开,或在手机上打开也行。`--no-browser` 不自动打开。第一次登录会自动开通体验应用。
17
+ 不需要填任何密钥;登录后令牌自动续期,30 天内不用再登录。凭证保存在 `~/.config/gowu/credentials.json`(只有本人可读)。
18
+
19
+ 可以登录多个应用:`gowu apps` 查看,`gowu use <应用名>` 切换;单条命令也可以用 `--app <应用名>` 指定。
20
+
21
+ ## 常用命令
22
+
23
+ ```bash
24
+ gowu search 保温杯 --max 300 --in-stock # 搜索(商品图以 Markdown 图片输出)
25
+ gowu product <商品id> # 详情与规格 skuId
26
+ gowu address add --name 张三 --phone 138… --detail "文三路 100 号" --region "浙江省 杭州市 西湖区"
27
+ gowu preview <商品id> <skuId> 1 # 结算试算
28
+ gowu buy <商品id> <skuId> 1 --address <地址id> # 下单
29
+ gowu orders / gowu order <订单号> / gowu logistics <订单号>
30
+ gowu refund list
31
+ ```
32
+
33
+ - **消费者购物**应用:`buy` 生成支付宝付款码(扫码或打开链接付款),`gowu payment <付款单号>` 查结果。
34
+ - **员工福利 / 企业采购**应用:`buy` 用企业额度下单,`--mixed` 混合支付(返回支付宝补差链接);另有 `points`(我的额度)、`org`(企业管理)、`tasks`(福豆任务)、`partner`(应用负责人绑定员工)、`upload`(上传任务图片)。
35
+
36
+ `gowu help` 只列出当前应用能用的命令;`gowu tools` 列出当前应用的全部工具,`gowu call <工具名> --args '{…}'` 可直接调用任意工具。所有命令加 `--json` 输出原始数据。
37
+
38
+ ## 说明
39
+
40
+ - 命令行不持有任何签名密钥:请求以登录令牌发到格物服务端,由服务端代为签名调用开放接口,权限与应用在开放平台开通的一致。
41
+ - 下单、退款等会花钱或改变状态的操作,请在执行前确认商品、规格、金额与地址。
42
+ - 文档:https://open.gowu.net/docs#cli
package/bin/gowu.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import("../dist/index.js").then((m) => m.main(process.argv.slice(2)));
package/dist/auth.js ADDED
@@ -0,0 +1,117 @@
1
+ import { resolveBase, saveCredentials } from "./config.js";
2
+ /**
3
+ * 设备码登录(RFC 8628):申请设备码 → 用户在任意设备的浏览器里输入短代码、手机号登录、选应用并授权 → 轮询换令牌。
4
+ * 不需要本机能打开浏览器或监听端口,Agent 的沙箱 / 远程环境里也能用。
5
+ */
6
+ const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
7
+ const CLIENT_NAME = "格物 CLI";
8
+ export class CliError extends Error {
9
+ code;
10
+ constructor(message, code = "ERROR") {
11
+ super(message);
12
+ this.code = code;
13
+ }
14
+ }
15
+ async function postForm(url, body) {
16
+ const res = await fetch(url, {
17
+ method: "POST",
18
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
19
+ body: new URLSearchParams(body).toString()
20
+ });
21
+ const data = (await res.json().catch(() => ({})));
22
+ return { status: res.status, data };
23
+ }
24
+ async function ensureClient(c) {
25
+ if (c.clientId)
26
+ return c.clientId;
27
+ const res = await fetch(`${resolveBase(c.base)}/oauth/register`, {
28
+ method: "POST",
29
+ headers: { "content-type": "application/json" },
30
+ body: JSON.stringify({ client_name: CLIENT_NAME, grant_types: [DEVICE_GRANT, "refresh_token"] })
31
+ });
32
+ const data = (await res.json().catch(() => ({})));
33
+ if (!res.ok || !data.client_id)
34
+ throw new CliError(`注册命令行客户端失败:${data.error_description ?? res.status}`);
35
+ c.clientId = data.client_id;
36
+ saveCredentials(c);
37
+ return data.client_id;
38
+ }
39
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
40
+ /** 第一步:申请设备码。返回给用户看的网址与授权码,不等待 */
41
+ export async function startDeviceLogin(c) {
42
+ const base = resolveBase(c.base);
43
+ let clientId = await ensureClient(c);
44
+ let start = await postForm(`${base}/oauth/device_authorization`, { client_id: clientId });
45
+ if (start.status === 400 && start.data.error === "invalid_client") {
46
+ // 客户端被平台停用或库被重置:重新注册一次
47
+ c.clientId = undefined;
48
+ clientId = await ensureClient(c);
49
+ start = await postForm(`${base}/oauth/device_authorization`, { client_id: clientId });
50
+ }
51
+ if (start.status !== 200)
52
+ throw new CliError(`申请授权码失败:${String(start.data.error_description ?? start.status)}`);
53
+ const d = start.data;
54
+ return {
55
+ deviceCode: d.device_code,
56
+ userCode: d.user_code,
57
+ verificationUri: d.verification_uri,
58
+ verificationUriComplete: d.verification_uri_complete,
59
+ interval: Math.max(1, d.interval || 5) * 1000,
60
+ expiresAt: Date.now() + d.expires_in * 1000
61
+ };
62
+ }
63
+ /** 第二步:轮询直到用户在浏览器里授权(或拒绝、过期)。`once` 只查一次,未授权时返回 null */
64
+ export async function pollDeviceLogin(c, p, opts = {}) {
65
+ if (!c.clientId)
66
+ throw new CliError("登录状态不完整,请重新执行 gowu login", "NEED_LOGIN");
67
+ const base = resolveBase(c.base);
68
+ let interval = p.interval;
69
+ let first = true;
70
+ while (Date.now() < p.expiresAt) {
71
+ if (!first || !opts.once)
72
+ await sleep(first && opts.once ? 0 : interval);
73
+ first = false;
74
+ const r = await postForm(`${base}/oauth/token`, { grant_type: DEVICE_GRANT, device_code: p.deviceCode, client_id: c.clientId });
75
+ if (r.status === 200 && r.data.access_token)
76
+ return r.data;
77
+ const err = String(r.data.error ?? "");
78
+ if (err === "authorization_pending" || err === "slow_down") {
79
+ if (err === "slow_down")
80
+ interval += 5000;
81
+ if (opts.once)
82
+ return null;
83
+ continue;
84
+ }
85
+ if (err === "access_denied")
86
+ throw new CliError("已在浏览器里拒绝授权", "ACCESS_DENIED");
87
+ if (err === "expired_token")
88
+ throw new CliError("授权码已过期,请重新执行 gowu login", "EXPIRED");
89
+ throw new CliError(`登录失败:${String(r.data.error_description ?? err ?? r.status)}`);
90
+ }
91
+ throw new CliError("等待授权超时,请重新执行 gowu login", "EXPIRED");
92
+ }
93
+ export function tokenToCredential(t, info) {
94
+ return {
95
+ appKey: info.appKey,
96
+ appName: info.appName,
97
+ realm: info.realm,
98
+ accessToken: t.access_token,
99
+ refreshToken: t.refresh_token,
100
+ expiresAt: Date.now() + t.expires_in * 1000,
101
+ scope: t.scope ?? "mcp"
102
+ };
103
+ }
104
+ /** 访问令牌快过期(剩不到 60 秒)或已被拒时用刷新令牌换新的;刷新令牌失效就要求重新登录 */
105
+ export async function refreshCredential(c, app) {
106
+ if (!c.clientId)
107
+ throw new CliError("登录状态不完整,请重新执行 gowu login", "NEED_LOGIN");
108
+ const r = await postForm(`${resolveBase(c.base)}/oauth/token`, { grant_type: "refresh_token", refresh_token: app.refreshToken, client_id: c.clientId });
109
+ if (r.status !== 200 || !r.data.access_token) {
110
+ throw new CliError(`登录已失效(${String(r.data.error_description ?? r.status)}),请重新执行 gowu login`, "NEED_LOGIN");
111
+ }
112
+ const t = r.data;
113
+ const next = { ...app, accessToken: t.access_token, refreshToken: t.refresh_token, expiresAt: Date.now() + t.expires_in * 1000 };
114
+ c.apps[app.appKey] = next;
115
+ saveCredentials(c);
116
+ return next;
117
+ }
@@ -0,0 +1,34 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * 登录时尽量自动打开授权页(gh auth login 同款体验)。打不开不影响登录:网址与授权码照样打印,用户可以手动打开、
4
+ * 或在手机上打开——设备码登录本来就是为打不开浏览器的环境(Agent 沙箱、远程服务器)设计的。
5
+ *
6
+ * 不自动打开的情况:--no-browser / GOWU_NO_BROWSER、CI、SSH 远程会话、Linux 没有图形界面。
7
+ */
8
+ export function canOpenBrowser(noBrowserFlag) {
9
+ const env = process.env;
10
+ if (noBrowserFlag || env.GOWU_NO_BROWSER || env.CI)
11
+ return false;
12
+ if (env.SSH_CONNECTION || env.SSH_TTY)
13
+ return false;
14
+ if (process.platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY)
15
+ return false;
16
+ return process.platform === "darwin" || process.platform === "win32" || process.platform === "linux";
17
+ }
18
+ export function openBrowser(url) {
19
+ try {
20
+ const [cmd, args] = process.platform === "darwin"
21
+ ? ["open", [url]]
22
+ : process.platform === "win32"
23
+ ? // start 的第一个带引号参数是窗口标题,所以先给个空标题
24
+ ["cmd", ["/c", "start", "", url.replace(/&/g, "^&")]]
25
+ : ["xdg-open", [url]];
26
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
27
+ child.on("error", () => undefined);
28
+ child.unref();
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
@@ -0,0 +1,548 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { CliError } from "./auth.js";
5
+ import { resolveBase, sceneOf } from "./config.js";
6
+ import * as fmt from "./format.js";
7
+ import { callTool, listTools } from "./mcp.js";
8
+ const str = (o, k) => (typeof o[k] === "string" ? o[k] : undefined);
9
+ const num = (o, k) => {
10
+ const v = str(o, k);
11
+ if (v === undefined)
12
+ return undefined;
13
+ const n = Number(v);
14
+ if (!Number.isFinite(n))
15
+ throw new CliError(`--${k} 应为数字`);
16
+ return n;
17
+ };
18
+ const need = (v, what) => {
19
+ if (!v)
20
+ throw new CliError(`缺少参数:${what}`, "USAGE");
21
+ return v;
22
+ };
23
+ const pick = (ctx, consumer, enterprise) => (ctx.scene === "enterprise" ? enterprise : consumer);
24
+ const genKey = () => `gowu-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${randomBytes(4).toString("hex")}`;
25
+ const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== ""));
26
+ /** 消费者下单每行都要 skuId:没给就看商品是不是只有一个可售规格 */
27
+ async function resolveSku(ctx, productId, skuId) {
28
+ if (skuId)
29
+ return skuId;
30
+ const p = (await ctx.tool(pick(ctx, "lm_get_product", "ent_get_product"), ctx.scene === "enterprise" ? { id: productId } : { productId }));
31
+ const sellable = (p.skus ?? []).filter((k) => k.skuId && k.canSell !== false && k.stock !== 0);
32
+ if (sellable.length === 1)
33
+ return sellable[0].skuId;
34
+ if (ctx.scene === "enterprise" && !(p.skus ?? []).length)
35
+ return undefined; // 企业购单规格商品可不传
36
+ if (!sellable.length)
37
+ throw new CliError("这个商品目前没有可购买的规格,换一个商品试试", "UNAVAILABLE");
38
+ throw new CliError(`这个商品有 ${sellable.length} 个可选规格,请指定 skuId(gowu product ${productId} 查看)`, "USAGE");
39
+ }
40
+ /** 「浙江省 杭州市 西湖区 [某某街道]」→ 区县码与乡镇码(逐级查区划,名称前缀匹配即可:「浙江」能对上「浙江省」) */
41
+ async function resolveRegion(ctx, region) {
42
+ const parts = region.split(/[\s,,/]+/).filter(Boolean);
43
+ if (parts.length < 3)
44
+ throw new CliError("--region 至少写到区县,例如「浙江省 杭州市 西湖区」", "USAGE");
45
+ let parent = "1";
46
+ const codes = [];
47
+ for (const part of parts.slice(0, 4)) {
48
+ const res = (await ctx.tool("lm_list_divisions", { parent }));
49
+ const items = res.items ?? [];
50
+ const hit = items.find((x) => x.name === part) ?? items.find((x) => x.name.startsWith(part) || part.startsWith(x.name));
51
+ if (!hit)
52
+ throw new CliError(`找不到「${part}」(可选:${items.slice(0, 12).map((x) => x.name).join("、")}…)`, "USAGE");
53
+ codes.push(hit);
54
+ parent = hit.code;
55
+ }
56
+ const county = codes.find((x) => x.level === 4) ?? codes[2];
57
+ const town = codes.find((x) => x.level === 5);
58
+ return { divisionCode: county.code, ...(town ? { townDivisionCode: town.code } : {}), names: codes.map((x) => x.name) };
59
+ }
60
+ async function upload(ctx, ticketTool, extra, file) {
61
+ const stat = statSync(file);
62
+ const ext = path.extname(file).toLowerCase();
63
+ const contentType = ext === ".png" ? "image/png" : ext === ".webp" ? "image/webp" : ext === ".gif" ? "image/gif" : "image/jpeg";
64
+ const t = (await ctx.tool(ticketTool, { ...extra, fileName: path.basename(file), contentType, size: stat.size }));
65
+ if (!t.putPath)
66
+ throw new CliError("没有拿到上传地址");
67
+ const res = await fetch(`${resolveBase(ctx.c.base)}${t.putPath}`, { method: "PUT", headers: { "content-type": contentType }, body: readFileSync(file) });
68
+ const data = (await res.json().catch(() => ({})));
69
+ if (!res.ok || data.ok === false)
70
+ throw new CliError(`上传失败:${data.message ?? res.status}`);
71
+ return data;
72
+ }
73
+ export const COMMANDS = [
74
+ // ── 购物 ──
75
+ {
76
+ name: "search",
77
+ usage: "search <关键词> [--min 价] [--max 价] [--in-stock] [--sort price_asc|price_desc|sales_desc] [--category 类目] [--page N] [--size N]",
78
+ summary: "搜索商品",
79
+ scene: "any",
80
+ group: "购物",
81
+ async run(ctx) {
82
+ const data = await ctx.tool(pick(ctx, "lm_search_products", "ent_search_products"), compact({
83
+ keyword: ctx.args.join(" ") || undefined,
84
+ priceMin: num(ctx.opts, "min"),
85
+ priceMax: num(ctx.opts, "max"),
86
+ inStockOnly: ctx.opts["in-stock"] ? true : undefined,
87
+ sortBy: str(ctx.opts, "sort"),
88
+ category: str(ctx.opts, "category"),
89
+ page: num(ctx.opts, "page"),
90
+ pageSize: num(ctx.opts, "size") ?? 10
91
+ }));
92
+ ctx.out(fmt.products(data), data);
93
+ }
94
+ },
95
+ {
96
+ name: "product",
97
+ usage: "product <商品id>",
98
+ summary: "商品详情与规格(skuId)",
99
+ scene: "any",
100
+ group: "购物",
101
+ async run(ctx) {
102
+ const id = need(ctx.args[0], "商品id");
103
+ const data = await ctx.tool(pick(ctx, "lm_get_product", "ent_get_product"), ctx.scene === "enterprise" ? { id } : { productId: id });
104
+ ctx.out(fmt.product(data), data);
105
+ }
106
+ },
107
+ {
108
+ name: "categories",
109
+ usage: "categories",
110
+ summary: "商品类目",
111
+ scene: "any",
112
+ group: "购物",
113
+ async run(ctx) {
114
+ const data = await ctx.tool(pick(ctx, "lm_list_categories", "ent_list_categories"), {});
115
+ ctx.out(fmt.json(data), data);
116
+ }
117
+ },
118
+ {
119
+ name: "preview",
120
+ usage: "preview <商品id> [skuId] [数量]",
121
+ summary: "结算试算(应付金额;企业购会显示额度是否够、是否要补差)",
122
+ scene: "any",
123
+ group: "购物",
124
+ async run(ctx) {
125
+ const productId = need(ctx.args[0], "商品id");
126
+ const skuId = await resolveSku(ctx, productId, ctx.args[1]);
127
+ const qty = Number(ctx.args[2] ?? 1);
128
+ const items = ctx.scene === "enterprise" ? [compact({ productId, skuId, qty })] : [{ productId, skuId, quantity: qty }];
129
+ const data = await ctx.tool(pick(ctx, "lm_checkout_preview", "ent_checkout_preview"), { items });
130
+ ctx.out(fmt.json(data), data);
131
+ }
132
+ },
133
+ {
134
+ name: "buy",
135
+ usage: "buy <商品id> [skuId] [数量] --address <地址id> [--key 幂等键] [--mixed] [--device pc|mobile] [--return-url URL]",
136
+ summary: "下单:消费者购物生成支付宝付款码;企业购用企业额度下单(--mixed 混合支付)",
137
+ scene: "any",
138
+ group: "购物",
139
+ async run(ctx) {
140
+ const productId = need(ctx.args[0], "商品id");
141
+ const skuId = await resolveSku(ctx, productId, ctx.args[1]);
142
+ const qty = Number(ctx.args[2] ?? 1);
143
+ const key = str(ctx.opts, "key") ?? genKey();
144
+ const addressId = need(str(ctx.opts, "address"), "--address <地址id>(gowu address list 查看,或 gowu address add 新增)");
145
+ const common = compact({ addressId, idempotencyKey: key, device: str(ctx.opts, "device"), returnUrl: str(ctx.opts, "return-url") });
146
+ if (ctx.scene === "enterprise") {
147
+ const data = await ctx.tool("ent_create_order", { ...common, items: [compact({ productId, skuId, qty })], payWith: ctx.opts.mixed ? "mixed" : "points" });
148
+ ctx.out(`${fmt.enterpriseOrder(data)}\n\n幂等键:\`${key}\`(重试时用 --key ${key},不会重复下单)`, data);
149
+ }
150
+ else {
151
+ const data = await ctx.tool("lm_create_payment_link", { ...common, items: [{ productId, skuId, quantity: qty }] });
152
+ ctx.out(`${fmt.paymentLink(data)}\n\n幂等键:\`${key}\`(重新获取同一张付款码时用 --key ${key})`, data);
153
+ }
154
+ }
155
+ },
156
+ {
157
+ name: "divisions",
158
+ usage: "divisions [上级code]",
159
+ summary: "行政区划逐级查询(从省开始)",
160
+ scene: "any",
161
+ group: "地址",
162
+ async run(ctx) {
163
+ const data = await ctx.tool("lm_list_divisions", compact({ parent: ctx.args[0] }));
164
+ ctx.out(fmt.json(data), data);
165
+ }
166
+ },
167
+ {
168
+ name: "address",
169
+ usage: "address list | add --name 收货人 --phone 手机 --detail 详细地址 --region \"省 市 区 [街道]\" [--default] | default <id> | delete <id>",
170
+ summary: "收货地址簿",
171
+ scene: "any",
172
+ group: "地址",
173
+ async run(ctx) {
174
+ const sub = ctx.args[0] ?? "list";
175
+ if (sub === "list") {
176
+ const data = await ctx.tool("buyer_list_addresses", {});
177
+ return ctx.out(fmt.json(data), data);
178
+ }
179
+ if (sub === "add") {
180
+ const region = await resolveRegion(ctx, need(str(ctx.opts, "region"), "--region \"省 市 区 [街道]\""));
181
+ const data = await ctx.tool("buyer_add_address", compact({
182
+ receiver: need(str(ctx.opts, "name"), "--name 收货人"),
183
+ receiverPhone: need(str(ctx.opts, "phone"), "--phone 手机号"),
184
+ addressDetail: need(str(ctx.opts, "detail"), "--detail 详细地址"),
185
+ divisionCode: region.divisionCode,
186
+ townDivisionCode: region.townDivisionCode,
187
+ isDefault: ctx.opts.default ? true : undefined
188
+ }));
189
+ return ctx.out(`已保存地址(${region.names.join(" ")})。\n\n${fmt.json(data)}`, data);
190
+ }
191
+ if (sub === "default" || sub === "delete") {
192
+ const addressId = need(ctx.args[1], "地址id");
193
+ const data = await ctx.tool(sub === "default" ? "buyer_set_default_address" : "buyer_delete_address", { addressId });
194
+ return ctx.out(fmt.json(data), data);
195
+ }
196
+ throw new CliError(`不认识的子命令:address ${sub}`, "USAGE");
197
+ }
198
+ },
199
+ // ── 订单与售后 ──
200
+ {
201
+ name: "orders",
202
+ usage: "orders [--limit N]",
203
+ summary: "订单列表",
204
+ scene: "any",
205
+ group: "订单与售后",
206
+ async run(ctx) {
207
+ const data = await ctx.tool(pick(ctx, "lm_list_orders", "ent_list_orders"), compact({ limit: num(ctx.opts, "limit") }));
208
+ ctx.out(fmt.json(data), data);
209
+ }
210
+ },
211
+ {
212
+ name: "order",
213
+ usage: "order <订单号>",
214
+ summary: "订单详情(企业购混合支付带 mixedPay 状态)",
215
+ scene: "any",
216
+ group: "订单与售后",
217
+ async run(ctx) {
218
+ const id = need(ctx.args[0], "订单号");
219
+ const data = await ctx.tool(pick(ctx, "lm_get_order", "ent_get_order"), ctx.scene === "enterprise" ? { bizOrderId: id } : { orderId: id });
220
+ ctx.out(fmt.json(data), data);
221
+ }
222
+ },
223
+ {
224
+ name: "logistics",
225
+ usage: "logistics <企业购订单号 | 消费者子订单id>",
226
+ summary: "物流轨迹",
227
+ scene: "any",
228
+ group: "订单与售后",
229
+ async run(ctx) {
230
+ const id = need(ctx.args[0], "订单号");
231
+ const data = await ctx.tool(pick(ctx, "lm_get_order_logistics", "ent_get_order_logistics"), ctx.scene === "enterprise" ? { bizOrderId: id } : { subOrderId: id });
232
+ ctx.out(fmt.json(data), data);
233
+ }
234
+ },
235
+ {
236
+ name: "receive",
237
+ usage: "receive <企业购订单号 | 消费者子订单id>",
238
+ summary: "确认收货",
239
+ scene: "any",
240
+ group: "订单与售后",
241
+ async run(ctx) {
242
+ const id = need(ctx.args[0], "订单号");
243
+ const data = ctx.scene === "enterprise" ? await ctx.tool("ent_order_action", { bizOrderId: id, action: "receive" }) : await ctx.tool("lm_confirm_receipt", { subOrderId: id });
244
+ ctx.out(fmt.json(data), data);
245
+ }
246
+ },
247
+ {
248
+ name: "payment",
249
+ usage: "payment [付款单号]",
250
+ summary: "付款与下单结果(不带单号列出最近的付款单)",
251
+ scene: "consumer",
252
+ group: "订单与售后",
253
+ async run(ctx) {
254
+ if (!ctx.args[0]) {
255
+ const data = await ctx.tool("lm_list_payments", compact({ limit: num(ctx.opts, "limit") }));
256
+ return ctx.out(fmt.json(data), data);
257
+ }
258
+ const data = await ctx.tool("lm_get_payment", { outTradeNo: ctx.args[0] });
259
+ ctx.out(fmt.payment(data), data);
260
+ }
261
+ },
262
+ {
263
+ name: "cancel",
264
+ usage: "cancel <订单号>",
265
+ summary: "取消订单(未发货)",
266
+ scene: "enterprise",
267
+ group: "订单与售后",
268
+ async run(ctx) {
269
+ const data = await ctx.tool("ent_order_action", { bizOrderId: need(ctx.args[0], "订单号"), action: "cancel" });
270
+ ctx.out(fmt.json(data), data);
271
+ }
272
+ },
273
+ {
274
+ name: "resume-pay",
275
+ usage: "resume-pay <订单号> [--device pc|mobile]",
276
+ summary: "混合支付:重新获取补差链接",
277
+ scene: "enterprise",
278
+ group: "订单与售后",
279
+ async run(ctx) {
280
+ const data = await ctx.tool("ent_order_action", compact({ bizOrderId: need(ctx.args[0], "订单号"), action: "resume-pay", device: str(ctx.opts, "device") }));
281
+ ctx.out(fmt.enterpriseOrder(data), data);
282
+ }
283
+ },
284
+ {
285
+ name: "refund",
286
+ usage: "refund list | get <售后单号> | cancel <售后单号> | 消费者:options <子订单id> <订单行id> · request <付款单号> --line <订单行id> [--reason-id ID] [--reason 说明] · return <付款单号> --line <订单行id> --company 快递公司 --no 运单号 | 企业购:request <订单号> [--sub 子订单] [--reason-id ID] [--reason 说明] [--amount 金额] [--need-return] · return <售后单号> --company 快递公司 --no 运单号",
287
+ summary: "售后:查看、申请、撤销、回传退货运单",
288
+ scene: "any",
289
+ group: "订单与售后",
290
+ async run(ctx) {
291
+ const sub = ctx.args[0] ?? "list";
292
+ const ent = ctx.scene === "enterprise";
293
+ let data;
294
+ if (sub === "list")
295
+ data = await ctx.tool(pick(ctx, "lm_list_refunds", "ent_list_refunds"), {});
296
+ else if (sub === "get")
297
+ data = await ctx.tool(pick(ctx, "lm_get_refund", "ent_get_refund"), ent ? { refundId: need(ctx.args[1], "售后单号") } : { disputeId: need(ctx.args[1], "售后单号") });
298
+ else if (sub === "cancel")
299
+ data = ent
300
+ ? await ctx.tool("ent_refund_action", { refundId: need(ctx.args[1], "售后单号"), action: "cancel" })
301
+ : await ctx.tool("lm_cancel_refund", { disputeId: need(ctx.args[1], "售后单号") });
302
+ else if (sub === "options" && !ent)
303
+ data = await ctx.tool("lm_refund_options", { subOrderId: need(ctx.args[1], "子订单id"), orderLineId: need(ctx.args[2], "订单行id") });
304
+ else if (sub === "request")
305
+ data = ent
306
+ ? await ctx.tool("ent_request_refund", compact({
307
+ bizOrderId: need(ctx.args[1], "订单号"),
308
+ subOrderId: str(ctx.opts, "sub"),
309
+ reasonId: str(ctx.opts, "reason-id"),
310
+ reasonText: str(ctx.opts, "reason"),
311
+ amount: num(ctx.opts, "amount"),
312
+ needReturn: ctx.opts["need-return"] ? true : undefined
313
+ }))
314
+ : await ctx.tool("lm_request_refund", compact({ outTradeNo: need(ctx.args[1], "付款单号"), orderLineId: str(ctx.opts, "line"), reasonId: str(ctx.opts, "reason-id"), reason: str(ctx.opts, "reason") }));
315
+ else if (sub === "return")
316
+ data = ent
317
+ ? await ctx.tool("ent_refund_action", compact({ refundId: need(ctx.args[1], "售后单号"), action: "return", logisticsCompany: need(str(ctx.opts, "company"), "--company"), invoiceNo: need(str(ctx.opts, "no"), "--no") }))
318
+ : await ctx.tool("lm_submit_return_shipment", compact({
319
+ outTradeNo: need(ctx.args[1], "付款单号"),
320
+ orderLineId: str(ctx.opts, "line"),
321
+ logisticsCompany: need(str(ctx.opts, "company"), "--company"),
322
+ invoiceNo: need(str(ctx.opts, "no"), "--no")
323
+ }));
324
+ else
325
+ throw new CliError(`不认识的子命令:refund ${sub}`, "USAGE");
326
+ ctx.out(fmt.json(data), data);
327
+ }
328
+ },
329
+ // ── 企业额度(员工) ──
330
+ {
331
+ name: "points",
332
+ usage: "points [summary | ledger [--type grant|order|refund|expire] | batches | rules]",
333
+ summary: "我的企业额度:余额、流水、到期",
334
+ scene: "enterprise",
335
+ group: "企业额度",
336
+ async run(ctx) {
337
+ const sub = ctx.args[0] ?? "summary";
338
+ const map = {
339
+ summary: ["ent_points_summary", {}],
340
+ ledger: ["ent_points_ledger", compact({ type: str(ctx.opts, "type"), page: num(ctx.opts, "page"), pageSize: num(ctx.opts, "size") })],
341
+ batches: ["ent_points_batches", {}],
342
+ rules: ["ent_points_rules", {}]
343
+ };
344
+ const hit = map[sub];
345
+ if (!hit)
346
+ throw new CliError(`不认识的子命令:points ${sub}`, "USAGE");
347
+ const data = await ctx.tool(hit[0], hit[1]);
348
+ ctx.out(fmt.json(data), data);
349
+ }
350
+ },
351
+ {
352
+ name: "checkin",
353
+ usage: "checkin [status]",
354
+ summary: "每日签到领额度(员工福利)",
355
+ scene: "enterprise",
356
+ group: "企业额度",
357
+ async run(ctx) {
358
+ const data = await ctx.tool(ctx.args[0] === "status" ? "ent_checkin_status" : "ent_checkin", {});
359
+ ctx.out(fmt.json(data), data);
360
+ }
361
+ },
362
+ {
363
+ name: "notifications",
364
+ usage: "notifications [read <id,id…>]",
365
+ summary: "站内信",
366
+ scene: "enterprise",
367
+ group: "企业额度",
368
+ async run(ctx) {
369
+ const data = ctx.args[0] === "read"
370
+ ? await ctx.tool("ent_mark_notifications_read", { ids: need(ctx.args[1], "站内信 id").split(",") })
371
+ : await ctx.tool("ent_list_notifications", {});
372
+ ctx.out(fmt.json(data), data);
373
+ }
374
+ },
375
+ // ── 企业管理(管理员) ──
376
+ {
377
+ name: "org",
378
+ usage: "org overview | dashboard | members | member <账号id|用户id> [--by-user] | grant <用户id> <额度> [--theme 类型] [--name 名称] [--days 有效天数] | revoke <用户id> <额度> | bills | consumption | departments | budgets",
379
+ summary: "企业管理:额度池、员工、发放额度、账单(管理员)",
380
+ scene: "enterprise",
381
+ group: "企业管理",
382
+ async run(ctx) {
383
+ const sub = ctx.args[0] ?? "overview";
384
+ let data;
385
+ const simple = {
386
+ overview: "ent_org_overview",
387
+ dashboard: "ent_org_dashboard",
388
+ members: "ent_org_list_members",
389
+ bills: "ent_org_bills",
390
+ consumption: "ent_org_consumption",
391
+ departments: "ent_org_list_departments",
392
+ budgets: "ent_org_list_budgets"
393
+ };
394
+ if (simple[sub])
395
+ data = await ctx.tool(simple[sub], {});
396
+ else if (sub === "member")
397
+ data = await ctx.tool("ent_org_get_member", compact({ accountId: need(ctx.args[1], "账号id"), by: ctx.opts["by-user"] ? "userId" : undefined }));
398
+ else if (sub === "grant" || sub === "revoke")
399
+ data = await ctx.tool("ent_org_grant_points", compact({
400
+ action: sub,
401
+ userId: need(ctx.args[1], "用户id"),
402
+ amount: Number(need(ctx.args[2], "额度(100 = 1 元)")),
403
+ theme: str(ctx.opts, "theme"),
404
+ name: str(ctx.opts, "name"),
405
+ expiresInDays: num(ctx.opts, "days")
406
+ }));
407
+ else
408
+ throw new CliError(`不认识的子命令:org ${sub}`, "USAGE");
409
+ ctx.out(fmt.json(data), data);
410
+ }
411
+ },
412
+ {
413
+ name: "upload",
414
+ usage: "upload cover <图片文件> | proof <任务id> <图片文件>",
415
+ summary: "上传任务封面(管理员)或任务凭证(员工),返回图片地址",
416
+ scene: "enterprise",
417
+ group: "任务",
418
+ async run(ctx) {
419
+ const sub = ctx.args[0];
420
+ const data = sub === "cover"
421
+ ? await upload(ctx, "ent_org_task_cover_ticket", {}, need(ctx.args[1], "图片文件"))
422
+ : sub === "proof"
423
+ ? await upload(ctx, "ent_task_proof_ticket", { taskId: need(ctx.args[1], "任务id") }, need(ctx.args[2], "图片文件"))
424
+ : (() => {
425
+ throw new CliError("用法:upload cover <文件> | upload proof <任务id> <文件>", "USAGE");
426
+ })();
427
+ ctx.out(fmt.json(data), data);
428
+ }
429
+ },
430
+ {
431
+ name: "tasks",
432
+ usage: "tasks [list [--redeem] | mine [--redeem] | get <任务id> | do <任务id> signup|complete|submit|redeem [--payload JSON]]",
433
+ summary: "福豆任务(员工):可参与的任务、报名、提交、兑换",
434
+ scene: "enterprise",
435
+ group: "任务",
436
+ async run(ctx) {
437
+ const sub = ctx.args[0] ?? "list";
438
+ let data;
439
+ if (sub === "list")
440
+ data = await ctx.tool("ent_list_tasks", { tab: ctx.opts.redeem ? "redeem" : "earn" });
441
+ else if (sub === "mine")
442
+ data = await ctx.tool("ent_my_tasks", { kind: ctx.opts.redeem ? "redeem" : "earn" });
443
+ else if (sub === "get")
444
+ data = await ctx.tool("ent_get_task", { taskId: need(ctx.args[1], "任务id") });
445
+ else if (sub === "do") {
446
+ const payload = str(ctx.opts, "payload");
447
+ data = await ctx.tool("ent_task_action", compact({ taskId: need(ctx.args[1], "任务id"), action: need(ctx.args[2], "动作"), payload: payload ? JSON.parse(payload) : undefined, idempotencyKey: genKey() }));
448
+ }
449
+ else
450
+ throw new CliError(`不认识的子命令:tasks ${sub}`, "USAGE");
451
+ ctx.out(fmt.json(data), data);
452
+ }
453
+ },
454
+ // ── 员工绑定(应用负责人) ──
455
+ {
456
+ name: "partner",
457
+ usage: "partner orgs | members <企业id> | bind <企业id> <用户id> [--role member|admin|owner] [--name 姓名] | bind-me [--role admin] | unbind <企业id> <用户id>",
458
+ summary: "应用名下的企业与员工绑定(仅应用负责人)",
459
+ scene: "enterprise",
460
+ group: "员工绑定",
461
+ async run(ctx) {
462
+ const sub = ctx.args[0] ?? "orgs";
463
+ let data;
464
+ if (sub === "orgs")
465
+ data = await ctx.tool("partner_list_orgs", {});
466
+ else if (sub === "members")
467
+ data = await ctx.tool("partner_list_members", { orgId: need(ctx.args[1], "企业id") });
468
+ else if (sub === "bind" || sub === "bind-me") {
469
+ let orgId = ctx.args[1];
470
+ let userId = ctx.args[2];
471
+ if (sub === "bind-me") {
472
+ // 把当前登录的自己绑成员工 / 管理员:自测企业购流程的第一步
473
+ const me = (await ctx.tool("buyer_get_profile", {}));
474
+ userId = me.account?.externalId;
475
+ const orgs = (await ctx.tool("partner_list_orgs", {}));
476
+ orgId = ctx.args[1] ?? orgs.items?.[0]?.orgId;
477
+ }
478
+ data = await ctx.tool("partner_bind_member", compact({ orgId: need(orgId, "企业id"), userId: need(userId, "用户id"), role: str(ctx.opts, "role") ?? (sub === "bind-me" ? "admin" : "member"), name: str(ctx.opts, "name") }));
479
+ }
480
+ else if (sub === "unbind")
481
+ data = await ctx.tool("partner_unbind_member", { orgId: need(ctx.args[1], "企业id"), userId: need(ctx.args[2], "用户id") });
482
+ else
483
+ throw new CliError(`不认识的子命令:partner ${sub}`, "USAGE");
484
+ ctx.out(fmt.json(data), data);
485
+ }
486
+ },
487
+ // ── 其他 ──
488
+ {
489
+ name: "tools",
490
+ usage: "tools [--verbose]",
491
+ summary: "当前应用能用的全部工具(gowu call 可直接调用)",
492
+ scene: "any",
493
+ group: "其他",
494
+ async run(ctx) {
495
+ const tools = await listTools(ctx.c, ctx.app);
496
+ const md = tools.map((t) => `- \`${t.name}\` ${t.title ?? ""}${ctx.opts.verbose ? `\n ${(t.description ?? "").slice(0, 300)}` : ""}`).join("\n");
497
+ ctx.out(`当前应用「${ctx.app.appName}」可用 ${tools.length} 个工具:\n\n${md}`, tools);
498
+ }
499
+ },
500
+ {
501
+ name: "call",
502
+ usage: "call <工具名> [--args '{JSON}'] [--参数名 值 …]",
503
+ summary: "直接调用任意工具(参数见 gowu tools --verbose)",
504
+ scene: "any",
505
+ group: "其他",
506
+ async run(ctx) {
507
+ const name = need(ctx.args[0], "工具名");
508
+ const input = {};
509
+ const raw = str(ctx.opts, "args");
510
+ if (raw)
511
+ Object.assign(input, JSON.parse(raw));
512
+ for (const [k, v] of Object.entries(ctx.opts)) {
513
+ if (k === "args" || k === "json" || k === "app")
514
+ continue;
515
+ if (typeof v === "boolean")
516
+ input[k] = v;
517
+ else
518
+ input[k] = /^-?\d+(\.\d+)?$/.test(v) ? Number(v) : /^[[{]/.test(v) ? JSON.parse(v) : v;
519
+ }
520
+ const data = await ctx.tool(name, input);
521
+ ctx.out(fmt.json(data), data);
522
+ }
523
+ }
524
+ ];
525
+ export function commandsFor(scene) {
526
+ return COMMANDS.filter((c) => c.scene === "any" || c.scene === scene);
527
+ }
528
+ export function makeCtx(c, app, args, opts, print) {
529
+ const scene = sceneOf(app.realm);
530
+ return {
531
+ c,
532
+ app,
533
+ scene,
534
+ args,
535
+ opts,
536
+ async tool(name, input) {
537
+ const r = await callTool(c, app, name, input);
538
+ if (!r.ok) {
539
+ const d = r.data;
540
+ throw new CliError(d?.message ?? r.text ?? "调用失败", d?.code ?? "ERROR");
541
+ }
542
+ return r.data;
543
+ },
544
+ out(markdown, data) {
545
+ print(opts.json ? fmt.json(data) : markdown);
546
+ }
547
+ };
548
+ }
package/dist/config.js ADDED
@@ -0,0 +1,69 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ /**
5
+ * 本机凭证:~/.config/gowu/credentials.json(XDG_CONFIG_HOME / Windows APPDATA 下同名目录),权限 600。
6
+ *
7
+ * 按 appKey 分别保存令牌:一个开发者可以同时登录体验应用与正式的员工福利应用,`gowu use` 切换当前应用。
8
+ * scope 字段预留——以后管理类操作要单独授权时,令牌带上权限范围,文件格式不用变。
9
+ */
10
+ export const DEFAULT_BASE = "https://api.gowu.net";
11
+ export function configDir() {
12
+ if (process.env.GOWU_CONFIG_DIR)
13
+ return process.env.GOWU_CONFIG_DIR;
14
+ if (process.platform === "win32" && process.env.APPDATA)
15
+ return path.join(process.env.APPDATA, "gowu");
16
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config"), "gowu");
17
+ }
18
+ const file = () => path.join(configDir(), "credentials.json");
19
+ /** 服务端地址:环境变量 GOWU_API_BASE(联调用)> 已登录时记下的 > 默认 */
20
+ export function resolveBase(saved) {
21
+ return (process.env.GOWU_API_BASE || saved || DEFAULT_BASE).replace(/\/+$/, "");
22
+ }
23
+ export function loadCredentials() {
24
+ try {
25
+ const c = JSON.parse(readFileSync(file(), "utf8"));
26
+ if (c && c.version === 1 && c.apps) {
27
+ // 换了服务端(如本地联调 → 线上):旧的客户端与令牌在新服务端无效
28
+ if (String(c.base ?? DEFAULT_BASE).replace(/\/+$/, "") !== resolveBase())
29
+ return { version: 1, base: resolveBase(), apps: {} };
30
+ return c;
31
+ }
32
+ }
33
+ catch {
34
+ // 没有或损坏:当作未登录
35
+ }
36
+ return { version: 1, base: resolveBase(), apps: {} };
37
+ }
38
+ export function saveCredentials(c) {
39
+ const dir = configDir();
40
+ if (!existsSync(dir))
41
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
42
+ // 先写临时文件再改名:写到一半被打断也不会留下半个 JSON
43
+ const tmp = `${file()}.${process.pid}.tmp`;
44
+ writeFileSync(tmp, JSON.stringify({ ...c, base: resolveBase(c.base) }, null, 2), { mode: 0o600 });
45
+ renameSync(tmp, file());
46
+ try {
47
+ chmodSync(file(), 0o600);
48
+ }
49
+ catch {
50
+ // Windows 不支持 chmod,靠用户目录权限
51
+ }
52
+ }
53
+ export function clearCredentials() {
54
+ rmSync(file(), { force: true });
55
+ }
56
+ export function currentApp(c, override) {
57
+ const key = override ? findAppKey(c, override) : c.current;
58
+ return key ? (c.apps[key] ?? null) : null;
59
+ }
60
+ /** 按 appKey 或应用名称找(`gowu use 福利测试应用`) */
61
+ export function findAppKey(c, nameOrKey) {
62
+ if (c.apps[nameOrKey])
63
+ return nameOrKey;
64
+ const hit = Object.values(c.apps).find((a) => a.appName === nameOrKey);
65
+ return hit?.appKey ?? null;
66
+ }
67
+ export function sceneOf(realm) {
68
+ return realm === "welfare" || realm === "procurement" ? "enterprise" : "consumer";
69
+ }
package/dist/format.js ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * 输出:默认是给人和 Agent 都好读的 Markdown(商品图、付款码用 ![…](…)),`--json` 输出接口原始数据。
3
+ * 没有专门格式的结果直接打印格式化的 JSON——Agent 解析 JSON 比解析排版可靠。
4
+ */
5
+ const s = (v) => (typeof v === "string" ? v : typeof v === "number" ? String(v) : "");
6
+ const yuan = (v) => {
7
+ const n = Number(v);
8
+ return Number.isFinite(n) && v !== null && v !== "" ? `¥${n.toFixed(2)}` : "";
9
+ };
10
+ /** 标题里的方括号、换行会打断 Markdown 图片语法 */
11
+ const alt = (t) => t.replace(/[[\]\r\n]/g, " ").slice(0, 60);
12
+ const img = (url, title) => {
13
+ let u = s(url).trim();
14
+ if (u.startsWith("//"))
15
+ u = `https:${u}`;
16
+ return /^https?:\/\//.test(u) ? `![${alt(title)}](${u.replace(/ /g, "%20").replace(/\(/g, "%28").replace(/\)/g, "%29")})` : "";
17
+ };
18
+ export function json(v) {
19
+ return JSON.stringify(v, null, 2);
20
+ }
21
+ export function products(data) {
22
+ const items = (data?.items ?? []);
23
+ if (!items.length)
24
+ return "没有找到符合条件的商品。";
25
+ const total = data.total;
26
+ const head = total !== undefined ? `共 ${s(total)} 件,以下 ${items.length} 件:\n\n` : "";
27
+ return (head +
28
+ items
29
+ .map((it, i) => {
30
+ const title = s(it.title) || "商品";
31
+ const meta = [
32
+ yuan(it.price),
33
+ it.marketPrice && Number(it.marketPrice) > Number(it.price) ? `原价 ${yuan(it.marketPrice)}` : "",
34
+ s(it.shopName),
35
+ it.availability === "out_of_stock" ? "缺货" : it.availability === "in_stock" ? "有货" : ""
36
+ ].filter(Boolean);
37
+ return [`${i + 1}. **${title}**`, img(it.imageUrl, title), meta.join(" · "), `商品 id:\`${s(it.id)}\``].filter(Boolean).join("\n\n");
38
+ })
39
+ .join("\n\n---\n\n"));
40
+ }
41
+ export function product(data) {
42
+ const p = data;
43
+ const title = s(p.title) || "商品";
44
+ const skus = (Array.isArray(p.skus) ? p.skus : []);
45
+ const lines = [
46
+ `**${title}**`,
47
+ img(p.imageUrl, title),
48
+ [yuan(p.price), s(p.shopName), s(p.brand)].filter(Boolean).join(" · "),
49
+ `商品 id:\`${s(p.id)}\``
50
+ ];
51
+ if (skus.length) {
52
+ lines.push("规格:\n" +
53
+ skus
54
+ .slice(0, 30)
55
+ .map((k) => {
56
+ const spec = k.specs && typeof k.specs === "object" ? Object.values(k.specs).map(s).filter(Boolean).join(" / ") : s(k.specs) || s(k.name);
57
+ const off = k.canSell === false || k.stock === 0 ? "(不可购买)" : "";
58
+ return `- ${spec || "默认规格"} ${yuan(k.price)}${off} skuId:\`${s(k.skuId)}\``;
59
+ })
60
+ .join("\n"));
61
+ }
62
+ else if (Array.isArray(p.skus)) {
63
+ lines.push("这个商品暂时不能下单(没有可售规格)。");
64
+ }
65
+ return lines.filter(Boolean).join("\n\n");
66
+ }
67
+ /** 消费者购物:付款码 */
68
+ export function paymentLink(data) {
69
+ const d = data;
70
+ return [
71
+ `已生成付款单 \`${s(d.outTradeNo)}\`,应付 **${d.money !== undefined ? `¥${s(d.money)}` : yuan(d.amount)}**。用手机支付宝扫码付款:`,
72
+ img(d.qrImageUrl, "付款码"),
73
+ `手机上也可以直接打开:${s(d.payUrl)}`,
74
+ "付款后平台自动下单;查结果:`gowu payment " + s(d.outTradeNo) + "`(status = fulfilled 即已下单)。付款期限约 23 小时,超时自动关单、不扣钱。"
75
+ ]
76
+ .filter(Boolean)
77
+ .join("\n\n");
78
+ }
79
+ /** 企业购:下单结果(全额额度或混合支付) */
80
+ export function enterpriseOrder(data) {
81
+ const d = data;
82
+ const id = s(d.bizOrderId) || s(d.order?.bizOrderId);
83
+ const lines = [`已下单,订单号 \`${id}\`。`];
84
+ if (s(d.launchUrl)) {
85
+ lines.push(`这是混合支付订单,员工还需用支付宝补差${d.cashCent !== undefined ? ` ¥${(Number(d.cashCent) / 100).toFixed(2)}` : ""}:`, `[去支付宝补差](${s(d.launchUrl)})(24 小时有效;过期用 \`gowu resume-pay ${id}\` 重新获取)`, `付完以订单状态为准:\`gowu order ${id}\`(mixedPay.status = confirmed 才算付清)。`);
86
+ }
87
+ return lines.join("\n\n");
88
+ }
89
+ export function payment(data) {
90
+ const d = data;
91
+ const status = s(d.status);
92
+ const label = {
93
+ pending_payment: "待付款",
94
+ paid: "已付款,正在下单",
95
+ fulfilling: "正在下单",
96
+ fulfilled: "已下单",
97
+ closed: "已关闭(超时未付,没扣钱)",
98
+ refund_pending: "下单失败,退款中",
99
+ refunded: "已退款"
100
+ };
101
+ return [
102
+ `付款单 \`${s(d.outTradeNo)}\`:**${label[status] ?? status}**`,
103
+ s(d.upstreamOrderId) ? `订单号:\`${s(d.upstreamOrderId)}\`(查看:\`gowu order ${s(d.upstreamOrderId)}\`)` : "",
104
+ d.money !== undefined ? `金额:¥${s(d.money)}` : ""
105
+ ]
106
+ .filter(Boolean)
107
+ .join("\n\n");
108
+ }
109
+ /** 结果里有 qrImageUrl / launchUrl 之类给用户点的东西时,额外提一句,Agent 不至于漏掉 */
110
+ export function fallback(data) {
111
+ return json(data);
112
+ }
package/dist/index.js ADDED
@@ -0,0 +1,202 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { CliError, pollDeviceLogin, startDeviceLogin, tokenToCredential } from "./auth.js";
3
+ import { COMMANDS, commandsFor, makeCtx } from "./commands.js";
4
+ import { clearCredentials, currentApp, findAppKey, loadCredentials, resolveBase, saveCredentials, sceneOf } from "./config.js";
5
+ import { callTool } from "./mcp.js";
6
+ import { canOpenBrowser, openBrowser } from "./browser.js";
7
+ /**
8
+ * gowu —— 格物开放平台命令行。
9
+ *
10
+ * gowu login 设备码登录:在浏览器里输入授权码、手机号登录、选应用
11
+ * gowu search 保温杯 --max 300
12
+ * gowu buy <商品id> <skuId> --address <地址id>
13
+ *
14
+ * 全局选项:--json 输出原始数据;--app <应用名|appKey> 本次用指定应用;环境变量 GOWU_API_BASE 指定服务端(联调用)。
15
+ */
16
+ const VERSION = (() => {
17
+ try {
18
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
19
+ }
20
+ catch {
21
+ return "0.0.0";
22
+ }
23
+ })();
24
+ /** --key value / --key=value / --flag;其余是位置参数。-- 之后全部当位置参数 */
25
+ export function parseArgs(argv) {
26
+ const args = [];
27
+ const opts = {};
28
+ let rest = false;
29
+ for (let i = 0; i < argv.length; i++) {
30
+ const a = argv[i];
31
+ if (rest || !a.startsWith("--") || a === "--") {
32
+ if (a === "--" && !rest)
33
+ rest = true;
34
+ else
35
+ args.push(a);
36
+ continue;
37
+ }
38
+ const eq = a.indexOf("=");
39
+ if (eq > 0) {
40
+ opts[a.slice(2, eq)] = a.slice(eq + 1);
41
+ continue;
42
+ }
43
+ const key = a.slice(2);
44
+ const next = argv[i + 1];
45
+ if (next !== undefined && !next.startsWith("--")) {
46
+ opts[key] = next;
47
+ i++;
48
+ }
49
+ else {
50
+ opts[key] = true;
51
+ }
52
+ }
53
+ const [cmd = "help", ...pos] = args;
54
+ return { cmd, args: pos, opts };
55
+ }
56
+ const print = (s) => void process.stdout.write(`${s}\n`);
57
+ const printErr = (s) => void process.stderr.write(`${s}\n`);
58
+ function help(scene, appName) {
59
+ const list = scene ? commandsFor(scene) : COMMANDS;
60
+ const groups = new Map();
61
+ for (const c of list) {
62
+ const g = groups.get(c.group) ?? [];
63
+ g.push(` gowu ${c.usage}\n ${c.summary}`);
64
+ groups.set(c.group, g);
65
+ }
66
+ return [
67
+ `gowu ${VERSION} · 格物开放平台命令行`,
68
+ appName ? `当前应用:${appName}(${scene === "enterprise" ? "企业购" : "消费者购物"})` : "还没登录:先执行 gowu login",
69
+ "",
70
+ "账号",
71
+ " gowu login 在浏览器里授权(自动打开授权页;--no-browser 不打开,也可在手机上完成)",
72
+ " Agent 用:gowu login --start 先拿网址与授权码,用户授权后 gowu login --finish",
73
+ " gowu whoami 当前应用与账号",
74
+ " gowu apps 已登录的应用;gowu use <应用名|appKey> 切换",
75
+ " gowu logout [--all] 退出当前应用(--all 全部)",
76
+ "",
77
+ ...[...groups.entries()].flatMap(([g, lines]) => [g, ...lines, ""]),
78
+ "全局选项:--json 输出原始数据 · --app <应用名|appKey> 本次使用指定应用",
79
+ "更多:https://open.gowu.net/docs#cli"
80
+ ].join("\n");
81
+ }
82
+ function codeMessage(v) {
83
+ return [
84
+ "请在浏览器打开下面的网址,用手机号登录并选择要授权的应用:",
85
+ "",
86
+ ` ${v.verificationUriComplete}`,
87
+ "",
88
+ ` (或打开 ${v.verificationUri} 手动输入授权码:${v.userCode})`,
89
+ "",
90
+ `授权码 ${Math.max(1, Math.round((v.expiresAt - Date.now()) / 60_000))} 分钟内有效。`
91
+ ].join("\n");
92
+ }
93
+ /**
94
+ * 登录。人在终端里直接 `gowu login`:打印网址后等待授权。
95
+ * Agent 分两步:`gowu login --start` 立即返回网址与授权码(转告用户),用户授权后 `gowu login --finish` 完成——
96
+ * 很多 Agent 要等命令结束才看得到输出,一直等待的 login 会让它没机会把网址告诉用户。
97
+ */
98
+ async function login(opts) {
99
+ const c = loadCredentials();
100
+ let pending = c.pending && c.pending.expiresAt > Date.now() ? c.pending : undefined;
101
+ if (opts.start || !opts.finish || !pending) {
102
+ if (opts.finish && !pending)
103
+ throw new CliError("没有进行中的登录,请先执行 gowu login --start", "USAGE");
104
+ pending = await startDeviceLogin(c);
105
+ c.pending = pending;
106
+ saveCredentials(c);
107
+ // 能打开就直接打开授权页(Agent 跑在用户电脑上时也会弹出,省得用户复制网址);打不开就靠打印的网址
108
+ const opened = canOpenBrowser(Boolean(opts["no-browser"])) && openBrowser(pending.verificationUriComplete);
109
+ if (opts.start) {
110
+ const data = { verificationUri: pending.verificationUri, verificationUriComplete: pending.verificationUriComplete, userCode: pending.userCode, expiresIn: Math.round((pending.expiresAt - Date.now()) / 1000) };
111
+ return print(opts.json
112
+ ? JSON.stringify({ ...data, browserOpened: opened }, null, 2)
113
+ : `${opened ? "已在浏览器中打开授权页。" : ""}${codeMessage(pending)}\n\n用户在浏览器里授权后,执行 gowu login --finish 完成登录。`);
114
+ }
115
+ print(`${opened ? "已在浏览器中打开授权页;没有弹出的话请手动打开下面的网址。\n\n" : ""}${codeMessage(pending)}\n正在等待授权…`);
116
+ }
117
+ const t = await pollDeviceLogin(c, pending, { once: Boolean(opts.once) });
118
+ if (!t)
119
+ return print(opts.json ? JSON.stringify({ ok: false, code: "PENDING", message: "用户还没有完成授权" }) : "用户还没有完成授权,稍后再执行 gowu login --finish。");
120
+ // 令牌里是哪个应用:问服务端
121
+ const probe = tokenToCredential(t, { appKey: "", appName: "", realm: null });
122
+ const who = await callTool(c, probe, "account_whoami", {});
123
+ const w = (who.data ?? {});
124
+ if (!w.appKey)
125
+ throw new CliError("登录成功但读取应用信息失败,请重试");
126
+ const cred = tokenToCredential(t, { appKey: w.appKey, appName: w.appName || w.appKey, realm: w.realm ?? null });
127
+ c.apps[cred.appKey] = cred;
128
+ c.current = cred.appKey;
129
+ delete c.pending;
130
+ saveCredentials(c);
131
+ const data = { appKey: cred.appKey, appName: cred.appName, realm: cred.realm };
132
+ print(opts.json
133
+ ? JSON.stringify(data, null, 2)
134
+ : `✓ 已登录「${cred.appName}」(${sceneOf(cred.realm) === "enterprise" ? "企业购" : "消费者购物"})。执行 gowu help 查看可用命令。`);
135
+ }
136
+ export async function main(argv) {
137
+ const { cmd, args, opts } = parseArgs(argv);
138
+ try {
139
+ if (cmd === "--version" || cmd === "version" || opts.version)
140
+ return print(VERSION);
141
+ if (cmd === "login")
142
+ return await login(opts);
143
+ const c = loadCredentials();
144
+ if (cmd === "logout") {
145
+ if (opts.all || Object.keys(c.apps).length <= 1) {
146
+ clearCredentials();
147
+ return print("已退出全部应用。");
148
+ }
149
+ const app = currentApp(c, typeof opts.app === "string" ? opts.app : undefined);
150
+ if (app)
151
+ delete c.apps[app.appKey];
152
+ c.current = Object.keys(c.apps)[0];
153
+ saveCredentials(c);
154
+ return print(`已退出「${app?.appName ?? ""}」${c.current ? `,当前应用:${c.apps[c.current].appName}` : ""}。`);
155
+ }
156
+ if (cmd === "apps") {
157
+ const list = Object.values(c.apps);
158
+ if (opts.json)
159
+ return print(JSON.stringify(list.map(({ appKey, appName, realm }) => ({ appKey, appName, realm, current: appKey === c.current })), null, 2));
160
+ if (!list.length)
161
+ return print("还没有登录任何应用,执行 gowu login。");
162
+ return print(list.map((a) => `${a.appKey === c.current ? "*" : " "} ${a.appName} ${a.appKey} ${sceneOf(a.realm) === "enterprise" ? "企业购" : "消费者购物"}`).join("\n"));
163
+ }
164
+ if (cmd === "use") {
165
+ const key = findAppKey(c, args[0] ?? "");
166
+ if (!key)
167
+ throw new CliError(`没有登录过「${args[0] ?? ""}」:gowu apps 查看已登录的应用,或 gowu login 再授权一个`, "USAGE");
168
+ c.current = key;
169
+ saveCredentials(c);
170
+ return print(`当前应用:${c.apps[key].appName}`);
171
+ }
172
+ const app = currentApp(c, typeof opts.app === "string" ? opts.app : undefined);
173
+ if (cmd === "help" || opts.help)
174
+ return print(help(app ? sceneOf(app.realm) : undefined, app?.appName));
175
+ if (!app)
176
+ throw new CliError("还没登录:先执行 gowu login", "NEED_LOGIN");
177
+ if (cmd === "whoami") {
178
+ const r = await callTool(c, app, "account_whoami", {});
179
+ const w = (r.data ?? {});
180
+ const data = { appKey: app.appKey, appName: app.appName, realm: app.realm, server: resolveBase(c.base), ...w };
181
+ return print(opts.json
182
+ ? JSON.stringify(data, null, 2)
183
+ : `应用:${app.appName}(${app.appKey})\n场景:${sceneOf(app.realm) === "enterprise" ? "企业购" : "消费者购物"}\n服务端:${resolveBase(c.base)}\n可用接口:${Array.isArray(w.allowedEndpoints) ? w.allowedEndpoints.length : "?"} 个`);
184
+ }
185
+ const scene = sceneOf(app.realm);
186
+ const command = COMMANDS.find((x) => x.name === cmd);
187
+ if (!command)
188
+ throw new CliError(`不认识的命令:${cmd}。执行 gowu help 查看可用命令`, "USAGE");
189
+ if (command.scene !== "any" && command.scene !== scene) {
190
+ throw new CliError(`「${cmd}」只在${command.scene === "enterprise" ? "企业购" : "消费者购物"}应用里可用,当前应用是「${app.appName}」。gowu use 切换应用`, "NOT_ALLOWED");
191
+ }
192
+ await command.run(makeCtx(c, app, args, opts, print));
193
+ }
194
+ catch (e) {
195
+ const err = e;
196
+ if (opts.json)
197
+ print(JSON.stringify({ ok: false, code: err.code ?? "ERROR", message: err.message }, null, 2));
198
+ else
199
+ printErr(`✗ ${err.message}`);
200
+ process.exitCode = err.code === "USAGE" ? 2 : 1;
201
+ }
202
+ }
package/dist/mcp.js ADDED
@@ -0,0 +1,73 @@
1
+ import { resolveBase } from "./config.js";
2
+ import { CliError, refreshCredential } from "./auth.js";
3
+ let rpcId = 0;
4
+ async function rpc(base, token, method, params) {
5
+ const res = await fetch(`${base}/mcp`, {
6
+ method: "POST",
7
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json, text/event-stream" },
8
+ body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method, params })
9
+ });
10
+ const raw = await res.text();
11
+ // 可能是 SSE(event: message / data: {...})也可能是纯 JSON
12
+ const line = raw.split("\n").filter((l) => l.startsWith("data:")).pop();
13
+ const json = line ? line.slice(5).trim() : raw.trim();
14
+ let body = null;
15
+ try {
16
+ body = JSON.parse(json);
17
+ }
18
+ catch {
19
+ body = null;
20
+ }
21
+ return { status: res.status, body };
22
+ }
23
+ async function withAuth(c, app, fn) {
24
+ let cur = app;
25
+ if (cur.expiresAt - Date.now() < 60_000)
26
+ cur = await refreshCredential(c, cur);
27
+ let r = await fn(cur.accessToken);
28
+ if (r.status === 401) {
29
+ cur = await refreshCredential(c, cur);
30
+ r = await fn(cur.accessToken);
31
+ if (r.status === 401)
32
+ throw new CliError("登录已失效,请重新执行 gowu login", "NEED_LOGIN");
33
+ }
34
+ return r.value;
35
+ }
36
+ export async function listTools(c, app) {
37
+ const base = resolveBase(c.base);
38
+ return withAuth(c, app, async (token) => {
39
+ const r = await rpc(base, token, "tools/list", {});
40
+ if (r.status === 401)
41
+ return { status: 401, value: [] };
42
+ const tools = (r.body?.result?.tools ?? []);
43
+ if (!r.body?.result)
44
+ throw new CliError(`获取工具列表失败(HTTP ${r.status})`);
45
+ return { status: r.status, value: tools };
46
+ });
47
+ }
48
+ export async function callTool(c, app, name, args) {
49
+ const base = resolveBase(c.base);
50
+ return withAuth(c, app, async (token) => {
51
+ const r = await rpc(base, token, "tools/call", { name, arguments: args });
52
+ if (r.status === 401)
53
+ return { status: 401, value: { ok: false, data: null, text: "", hints: [] } };
54
+ const err = r.body?.error;
55
+ if (err) {
56
+ const msg = String(err.message ?? "");
57
+ if (/not found/i.test(msg))
58
+ throw new CliError(`当前应用没有开放这个功能(${name})。用 gowu tools 查看可用功能,或用 gowu use 切换应用`, "NOT_ALLOWED");
59
+ throw new CliError(msg || `调用失败(HTTP ${r.status})`);
60
+ }
61
+ const result = (r.body?.result ?? {});
62
+ const texts = (result.content ?? []).filter((x) => x.type === "text").map((x) => x.text ?? "");
63
+ const [first = "", ...rest] = texts;
64
+ let data = first;
65
+ try {
66
+ data = JSON.parse(first);
67
+ }
68
+ catch {
69
+ // 不是 JSON 就当文本
70
+ }
71
+ return { status: r.status, value: { ok: !result.isError, data, text: first, hints: rest } };
72
+ });
73
+ }
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@gowuai/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "格物(GoWu)开放平台命令行:搜索商品、下单付款、订单售后、企业购额度与企业管理。给人用,也给 AI Agent 用。",
5
+ "type": "module",
6
+ "bin": {
7
+ "gowu": "bin/gowu.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "dist",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18.17"
16
+ },
17
+ "scripts": {
18
+ "build": "tsc -p tsconfig.json",
19
+ "dev": "tsx src/index.ts",
20
+ "test": "tsx --test \"src/**/*.test.ts\"",
21
+ "prepublishOnly": "tsc -p tsconfig.json"
22
+ },
23
+ "keywords": [
24
+ "gowu",
25
+ "shopping",
26
+ "mcp",
27
+ "agent",
28
+ "cli"
29
+ ],
30
+ "homepage": "https://open.gowu.net/docs#cli",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/gowuai/cli.git"
34
+ },
35
+ "license": "MIT",
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^22.0.0",
41
+ "tsx": "^4.21.0",
42
+ "typescript": "^5.9.3"
43
+ }
44
+ }