@puyinkai/xiaobao-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -0
- package/dist/api-Ccj5dLMo.mjs +100 -0
- package/dist/api-client-DwySN6x-.mjs +75 -0
- package/dist/audio-VmDQMq59.mjs +14 -0
- package/dist/auth-ChHvqjCS.mjs +15 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +30 -0
- package/dist/consultant-ABFTL_jx.mjs +11 -0
- package/dist/customer-DtSnQqy_.mjs +11 -0
- package/dist/device-flow-BgsZipYA.mjs +166 -0
- package/dist/focus-BDYuP2vZ.mjs +11 -0
- package/dist/format-BZvv8lYc.mjs +610 -0
- package/dist/headers-D79npewp.mjs +11 -0
- package/dist/list--o5Q_PI8.mjs +80 -0
- package/dist/list-Bkyi95YO.mjs +76 -0
- package/dist/list-CGACp0y-.mjs +76 -0
- package/dist/list-CVKSGNu4.mjs +91 -0
- package/dist/list-CzB9_Jho.mjs +91 -0
- package/dist/list-DW6KAAaC.mjs +92 -0
- package/dist/list-iwd9cH1I.mjs +41 -0
- package/dist/login-BwkrByMm.mjs +91 -0
- package/dist/logout-BJm9pjje.mjs +49 -0
- package/dist/project-Ba05YzJ5.mjs +14 -0
- package/dist/project-store-Bz5kf-EI.mjs +62 -0
- package/dist/qa-D-B9FV9W.mjs +60 -0
- package/dist/resistance-Cf7zsdB3.mjs +11 -0
- package/dist/text-BLH4R3xv.mjs +49 -0
- package/dist/token-store-CHZ_rJQk.mjs +85 -0
- package/dist/use-DV9Ii3Tn.mjs +61 -0
- package/dist/util-DgwkUfV9.mjs +46 -0
- package/dist/visit-Ct3Vea8I.mjs +11 -0
- package/dist/whoami-DwTHvI4B.mjs +51 -0
- package/package.json +36 -0
- package/skills/wangxiaobao-audio-query/SKILL.md +252 -0
- package/skills/wangxiaobao-audio-wiki/SKILL.md +332 -0
- package/skills/wangxiaobao-customer-focus-query/SKILL.md +213 -0
- package/skills/wangxiaobao-customer-query/SKILL.md +214 -0
- package/skills/wangxiaobao-customer-resistance-query/SKILL.md +211 -0
- package/skills/wangxiaobao-quick-qa/SKILL.md +238 -0
- package/skills/wangxiaobao-switch-project/SKILL.md +179 -0
- package/skills/wangxiaobao-visit-query/SKILL.md +249 -0
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# @puyinkai/xiaobao-cli
|
|
2
|
+
|
|
3
|
+
旺小宝 CLI —— [openclaw-xiaobao](https://github.com/puyinkai/openclaw-xiaobao)
|
|
4
|
+
plugin 的 host-agnostic 等价物。14 个能力 1:1 映射为 14 个 CLI 子命令,**人类
|
|
5
|
+
直接敲也好用,AI agent(Claude Code / Codex / Cursor / OpenClaw / 任何能 shell
|
|
6
|
+
out 的 host)通过 stdout JSON 消费**。
|
|
7
|
+
|
|
8
|
+
参考 [larksuite/cli](https://github.com/larksuite/cli) 范式,跟 OpenClaw plugin
|
|
9
|
+
解耦:plugin 这条线(`@puyinkai/openclaw-xiaobao`)保持现状,本 CLI 走 npm,
|
|
10
|
+
两条线并存。
|
|
11
|
+
|
|
12
|
+
## 安装
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install -g @puyinkai/xiaobao-cli
|
|
16
|
+
# 或 npx 一次性用
|
|
17
|
+
npx -y @puyinkai/xiaobao-cli --help
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 快速上手
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
xiaobao-cli auth login # 走 device flow 登录
|
|
24
|
+
xiaobao-cli project list --keyword 盛世 # 找项目
|
|
25
|
+
xiaobao-cli project use --tenant-id 1234 --tenant-name 示例租户 \
|
|
26
|
+
--project-id 9001 --project-name 盛世禧悦 # 激活项目
|
|
27
|
+
xiaobao-cli audio list --from "2026-05-01 00:00:00" --to "2026-05-02 00:00:00" --size 5
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
> openclaw-xiaobao 老用户:CLI 默认从 `~/.openclaw/state/wangxiaobao/` fallback
|
|
31
|
+
> 读 token + active-project,**零迁移开箱即用**。新写操作落到 `~/.xiaobao/`。
|
|
32
|
+
|
|
33
|
+
## 14 个能力
|
|
34
|
+
|
|
35
|
+
| 命令 | 对应 openclaw-xiaobao tool |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `auth login [--force]` | `xiaobao_authorize` |
|
|
38
|
+
| `auth whoami` | `xiaobao_whoami` |
|
|
39
|
+
| `auth logout` | `xiaobao_logout` |
|
|
40
|
+
| `project list [--keyword <kw>]` | `xiaobao_list_projects` |
|
|
41
|
+
| `project use --tenant-id ... --project-id ...` | `xiaobao_switch_project` |
|
|
42
|
+
| `consultant list` | `xiaobao_list_consultants` |
|
|
43
|
+
| `audio list --from --to [--user-id] ...` | `xiaobao_list_audio` |
|
|
44
|
+
| `audio text <audio-id>` | `xiaobao_get_audio_text` |
|
|
45
|
+
| `customer list [--customer-name] [--portrait] ...` | `xiaobao_list_customers` |
|
|
46
|
+
| `visit list [--customer-id] [--from] [--to]` | `xiaobao_list_visits` |
|
|
47
|
+
| `focus list [--visit-ids] [--category] ...` | `xiaobao_list_customer_focus` |
|
|
48
|
+
| `resistance list [...]` | `xiaobao_list_customer_resistance` |
|
|
49
|
+
| `qa <prompt> [--thread-id <id>]` | `xiaobao_quick_qa` |
|
|
50
|
+
| `api <METHOD> <path>` | `xiaobao_api` |
|
|
51
|
+
|
|
52
|
+
## 通用 flags
|
|
53
|
+
|
|
54
|
+
- `--format json|toon|table` —— 默认 `json`(agent 友好);`table` 人类友好;`toon` 给 LLM 上下文用
|
|
55
|
+
- `--api-base <url>` / `--auth-base <url>` —— 覆盖默认(也可 env `XIAOBAO_API_BASE` / `XIAOBAO_AUTH_BASE`)
|
|
56
|
+
|
|
57
|
+
## 输出协议
|
|
58
|
+
|
|
59
|
+
- stdout 写**结果**(`--format` 决定形态)
|
|
60
|
+
- stderr 写**进度 / 日志 / 错误**(不污染 stdout)
|
|
61
|
+
- 退出码:成功 0,业务错误(401/NO_ACTIVE_PROJECT/参数错)非 0
|
|
62
|
+
- 错误对象同时打到 stdout 保持可 parse
|
|
63
|
+
|
|
64
|
+
## state 存储
|
|
65
|
+
|
|
66
|
+
| 文件 | 路径 | 权限 |
|
|
67
|
+
| --- | --- | --- |
|
|
68
|
+
| OAuth tokens | `~/.xiaobao/token.json` | 0600 |
|
|
69
|
+
| Active project | `~/.xiaobao/active-project.json` | 0600 |
|
|
70
|
+
| User config(可选) | `~/.xiaobao/config.json` | 0600 |
|
|
71
|
+
|
|
72
|
+
读取顺序:`~/.xiaobao/` → fallback `~/.openclaw/state/wangxiaobao/`。写永远到 `~/.xiaobao/`。
|
|
73
|
+
|
|
74
|
+
## skill 配套
|
|
75
|
+
|
|
76
|
+
`skills/` 目录下 8 个 SKILL.md 跟 cli 同包发布,跟 openclaw-xiaobao 那边镜像,
|
|
77
|
+
只是调用从 plugin tool invoke 改为 shell out `xiaobao-cli ...`。
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { a as resolveConfig } from "./device-flow-BgsZipYA.mjs";
|
|
2
|
+
import { t as xbApiFetch } from "./api-client-DwySN6x-.mjs";
|
|
3
|
+
import { n as writeResult, t as writeError } from "./format-BZvv8lYc.mjs";
|
|
4
|
+
import { defineCommand } from "citty";
|
|
5
|
+
//#region src/commands/api.ts
|
|
6
|
+
/**
|
|
7
|
+
* `xiaobao-cli api <METHOD> <path>` — generic authenticated wangxiaobao API call.
|
|
8
|
+
*
|
|
9
|
+
* Bearer token auto-injected, 401 → refresh + retry once. Use for endpoints
|
|
10
|
+
* not yet wrapped as a dedicated command, or for one-off API exploration.
|
|
11
|
+
*/
|
|
12
|
+
const METHODS = new Set([
|
|
13
|
+
"GET",
|
|
14
|
+
"POST",
|
|
15
|
+
"PUT",
|
|
16
|
+
"PATCH",
|
|
17
|
+
"DELETE"
|
|
18
|
+
]);
|
|
19
|
+
/** Parse `k1=v1&k2=v2` or `k=v,k=v` into a Record. */
|
|
20
|
+
function parseKV(input) {
|
|
21
|
+
if (!input) return void 0;
|
|
22
|
+
const out = {};
|
|
23
|
+
const pairs = input.includes("&") ? input.split("&") : input.split(",");
|
|
24
|
+
for (const pair of pairs) {
|
|
25
|
+
const trimmed = pair.trim();
|
|
26
|
+
if (!trimmed) continue;
|
|
27
|
+
const idx = trimmed.indexOf("=");
|
|
28
|
+
if (idx < 0) continue;
|
|
29
|
+
out[trimmed.slice(0, idx).trim()] = trimmed.slice(idx + 1).trim();
|
|
30
|
+
}
|
|
31
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
32
|
+
}
|
|
33
|
+
var api_default = defineCommand({
|
|
34
|
+
meta: {
|
|
35
|
+
name: "api",
|
|
36
|
+
description: "generic authenticated API call — 自动注入 Bearer + 401 自动 refresh(探索/调试用)"
|
|
37
|
+
},
|
|
38
|
+
args: {
|
|
39
|
+
method: {
|
|
40
|
+
type: "positional",
|
|
41
|
+
required: true,
|
|
42
|
+
description: "GET / POST / PUT / PATCH / DELETE"
|
|
43
|
+
},
|
|
44
|
+
path: {
|
|
45
|
+
type: "positional",
|
|
46
|
+
required: true,
|
|
47
|
+
description: "API 相对路径,如 `/saas/v2/estate/tenant-and-estate/by-user-id`"
|
|
48
|
+
},
|
|
49
|
+
query: {
|
|
50
|
+
type: "string",
|
|
51
|
+
description: "查询串,`k1=v1&k2=v2` 或 `k=v,k=v`"
|
|
52
|
+
},
|
|
53
|
+
body: {
|
|
54
|
+
type: "string",
|
|
55
|
+
description: "JSON body(POST/PUT/PATCH 用,传 JSON 字符串)"
|
|
56
|
+
},
|
|
57
|
+
headers: {
|
|
58
|
+
type: "string",
|
|
59
|
+
description: "自定义 headers,`K1=V1&K2=V2` 或 `K=V,K=V`(如 X-Tenant-Id=...&X-Project-Id=...)"
|
|
60
|
+
},
|
|
61
|
+
"api-base": { type: "string" },
|
|
62
|
+
"auth-base": { type: "string" },
|
|
63
|
+
format: {
|
|
64
|
+
type: "string",
|
|
65
|
+
default: "json"
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
async run({ args }) {
|
|
69
|
+
try {
|
|
70
|
+
const method = String(args.method).toUpperCase();
|
|
71
|
+
if (!METHODS.has(method)) {
|
|
72
|
+
writeError({
|
|
73
|
+
code: "BAD_METHOD",
|
|
74
|
+
message: `unknown method: ${args.method}`
|
|
75
|
+
}, args.format);
|
|
76
|
+
process.exit(2);
|
|
77
|
+
}
|
|
78
|
+
const config = resolveConfig({
|
|
79
|
+
apiBase: args["api-base"],
|
|
80
|
+
authBase: args["auth-base"]
|
|
81
|
+
});
|
|
82
|
+
let body;
|
|
83
|
+
if (args.body) try {
|
|
84
|
+
body = JSON.parse(args.body);
|
|
85
|
+
} catch {
|
|
86
|
+
body = args.body;
|
|
87
|
+
}
|
|
88
|
+
writeResult(await xbApiFetch(config, method, String(args.path), {
|
|
89
|
+
query: parseKV(args.query),
|
|
90
|
+
body,
|
|
91
|
+
headers: parseKV(args.headers)
|
|
92
|
+
}), args.format);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
writeError(err, args.format);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
//#endregion
|
|
100
|
+
export { api_default as default };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { r as refreshToken } from "./device-flow-BgsZipYA.mjs";
|
|
2
|
+
import { a as writeToken, i as readToken, r as isAccessTokenValid } from "./token-store-CHZ_rJQk.mjs";
|
|
3
|
+
//#region src/core/api-client.ts
|
|
4
|
+
/**
|
|
5
|
+
* HTTP client for wangxiaobao open API endpoints.
|
|
6
|
+
*
|
|
7
|
+
* Port of openclaw-xiaobao/src/core/api-client.ts. Difference: no
|
|
8
|
+
* workspaceDir param (CLI has a single global token, not per-agent
|
|
9
|
+
* isolation).
|
|
10
|
+
*
|
|
11
|
+
* Adds `Authorization: Bearer <accessToken>` automatically and retries once
|
|
12
|
+
* with a refreshed token on HTTP 401.
|
|
13
|
+
*/
|
|
14
|
+
var NotAuthenticatedError = class extends Error {
|
|
15
|
+
code = "NOT_AUTHENTICATED";
|
|
16
|
+
constructor() {
|
|
17
|
+
super("No valid token cached. Run `xiaobao-cli auth login` first.");
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
async function ensureAccessToken(config) {
|
|
21
|
+
const cached = await readToken();
|
|
22
|
+
if (isAccessTokenValid(cached)) return cached.access_token;
|
|
23
|
+
if (cached?.refresh_token) return (await writeToken(await refreshToken(config, cached.refresh_token))).access_token;
|
|
24
|
+
throw new NotAuthenticatedError();
|
|
25
|
+
}
|
|
26
|
+
function buildUrl(apiBase, path, query) {
|
|
27
|
+
const url = new URL(path, apiBase);
|
|
28
|
+
if (query) for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v);
|
|
29
|
+
return url.toString();
|
|
30
|
+
}
|
|
31
|
+
async function doFetch(url, method, token, opts) {
|
|
32
|
+
const headers = {
|
|
33
|
+
Accept: "application/json",
|
|
34
|
+
Authorization: `Bearer ${token}`,
|
|
35
|
+
...opts.headers ?? {}
|
|
36
|
+
};
|
|
37
|
+
let body;
|
|
38
|
+
if (opts.body != null && method !== "GET" && method !== "DELETE") if (typeof opts.body === "string") body = opts.body;
|
|
39
|
+
else {
|
|
40
|
+
body = JSON.stringify(opts.body);
|
|
41
|
+
if (!headers["Content-Type"]) headers["Content-Type"] = "application/json";
|
|
42
|
+
}
|
|
43
|
+
const resp = await fetch(url, {
|
|
44
|
+
method,
|
|
45
|
+
headers,
|
|
46
|
+
body,
|
|
47
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 3e4)
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
resp,
|
|
51
|
+
parsed: (resp.headers.get("content-type") || "").includes("application/json") ? await resp.json() : await resp.text()
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Make an authenticated request to the wangxiaobao API. Refreshes on 401 once.
|
|
56
|
+
*/
|
|
57
|
+
async function xbApiFetch(config, method, path, opts = {}) {
|
|
58
|
+
const url = buildUrl(config.apiBase, path, opts.query);
|
|
59
|
+
let token = await ensureAccessToken(config);
|
|
60
|
+
let { resp, parsed } = await doFetch(url, method, token, opts);
|
|
61
|
+
if (resp.status === 401) {
|
|
62
|
+
const cached = await readToken();
|
|
63
|
+
if (cached?.refresh_token) {
|
|
64
|
+
token = (await writeToken(await refreshToken(config, cached.refresh_token))).access_token;
|
|
65
|
+
({resp, parsed} = await doFetch(url, method, token, opts));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
status: resp.status,
|
|
70
|
+
ok: resp.ok,
|
|
71
|
+
data: parsed
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
export { xbApiFetch as t };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
//#region src/commands/audio/index.ts
|
|
3
|
+
var audio_default = defineCommand({
|
|
4
|
+
meta: {
|
|
5
|
+
name: "audio",
|
|
6
|
+
description: "录音元数据分页 / 单条文本查询"
|
|
7
|
+
},
|
|
8
|
+
subCommands: {
|
|
9
|
+
list: () => import("./list--o5Q_PI8.mjs").then((m) => m.default),
|
|
10
|
+
text: () => import("./text-BLH4R3xv.mjs").then((m) => m.default)
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
//#endregion
|
|
14
|
+
export { audio_default as default };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
//#region src/commands/auth/index.ts
|
|
3
|
+
var auth_default = defineCommand({
|
|
4
|
+
meta: {
|
|
5
|
+
name: "auth",
|
|
6
|
+
description: "OAuth 登录 / 身份查询 / 登出(device flow)"
|
|
7
|
+
},
|
|
8
|
+
subCommands: {
|
|
9
|
+
login: () => import("./login-BwkrByMm.mjs").then((m) => m.default),
|
|
10
|
+
whoami: () => import("./whoami-DwTHvI4B.mjs").then((m) => m.default),
|
|
11
|
+
logout: () => import("./logout-BJm9pjje.mjs").then((m) => m.default)
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
//#endregion
|
|
15
|
+
export { auth_default as default };
|
package/dist/cli.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { defineCommand, runMain } from "citty";
|
|
3
|
+
//#region src/cli.ts
|
|
4
|
+
/**
|
|
5
|
+
* xiaobao-cli entrypoint.
|
|
6
|
+
*
|
|
7
|
+
* 14 capabilities mapped 1:1 from openclaw-xiaobao plugin tools to CLI
|
|
8
|
+
* subcommands. See README for full command map.
|
|
9
|
+
*/
|
|
10
|
+
runMain(defineCommand({
|
|
11
|
+
meta: {
|
|
12
|
+
name: "xiaobao-cli",
|
|
13
|
+
version: "0.1.0",
|
|
14
|
+
description: "旺小宝 CLI — host-agnostic 14 tools (openclaw-xiaobao plugin 的 CLI 等价物)"
|
|
15
|
+
},
|
|
16
|
+
subCommands: {
|
|
17
|
+
auth: () => import("./auth-ChHvqjCS.mjs").then((m) => m.default),
|
|
18
|
+
project: () => import("./project-Ba05YzJ5.mjs").then((m) => m.default),
|
|
19
|
+
consultant: () => import("./consultant-ABFTL_jx.mjs").then((m) => m.default),
|
|
20
|
+
audio: () => import("./audio-VmDQMq59.mjs").then((m) => m.default),
|
|
21
|
+
customer: () => import("./customer-DtSnQqy_.mjs").then((m) => m.default),
|
|
22
|
+
visit: () => import("./visit-Ct3Vea8I.mjs").then((m) => m.default),
|
|
23
|
+
focus: () => import("./focus-BDYuP2vZ.mjs").then((m) => m.default),
|
|
24
|
+
resistance: () => import("./resistance-Cf7zsdB3.mjs").then((m) => m.default),
|
|
25
|
+
qa: () => import("./qa-D-B9FV9W.mjs").then((m) => m.default),
|
|
26
|
+
api: () => import("./api-Ccj5dLMo.mjs").then((m) => m.default)
|
|
27
|
+
}
|
|
28
|
+
}));
|
|
29
|
+
//#endregion
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
//#region src/commands/consultant/index.ts
|
|
3
|
+
var consultant_default = defineCommand({
|
|
4
|
+
meta: {
|
|
5
|
+
name: "consultant",
|
|
6
|
+
description: "查询有权限的置业顾问列表"
|
|
7
|
+
},
|
|
8
|
+
subCommands: { list: () => import("./list-iwd9cH1I.mjs").then((m) => m.default) }
|
|
9
|
+
});
|
|
10
|
+
//#endregion
|
|
11
|
+
export { consultant_default as default };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
//#region src/commands/customer/index.ts
|
|
3
|
+
var customer_default = defineCommand({
|
|
4
|
+
meta: {
|
|
5
|
+
name: "customer",
|
|
6
|
+
description: "客户画像分页查询"
|
|
7
|
+
},
|
|
8
|
+
subCommands: { list: () => import("./list-DW6KAAaC.mjs").then((m) => m.default) }
|
|
9
|
+
});
|
|
10
|
+
//#endregion
|
|
11
|
+
export { customer_default as default };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
//#region src/core/config.ts
|
|
5
|
+
/**
|
|
6
|
+
* CLI configuration resolution — flat, host-agnostic version of
|
|
7
|
+
* openclaw-xiaobao/src/core/config.ts.
|
|
8
|
+
*
|
|
9
|
+
* Sources (precedence high → low):
|
|
10
|
+
* 1. CLI flags `--api-base`, `--auth-base` (passed in via the `overrides` arg)
|
|
11
|
+
* 2. Env vars `XIAOBAO_API_BASE`, `XIAOBAO_AUTH_BASE`, `XIAOBAO_SCOPES`
|
|
12
|
+
* 3. ~/.xiaobao/config.json { authBase?, apiBase?, scopes? } if present
|
|
13
|
+
* 4. Hardcoded defaults (same phoenix endpoints + client_id as plugin)
|
|
14
|
+
*
|
|
15
|
+
* client_id / client_secret are hardcoded — they're app-level secrets owned
|
|
16
|
+
* by the publisher (xiaobao-cli), not per-user. Identical to the plugin's
|
|
17
|
+
* `open-ai-gateway` RegisteredClient seeded by phoenix DevClientBootstrap.
|
|
18
|
+
*/
|
|
19
|
+
const DEFAULT_AUTH_BASE = "https://admin.wangxiaobao.com";
|
|
20
|
+
const DEFAULT_API_BASE = "https://open-ai.wangxiaobao.com";
|
|
21
|
+
const DEFAULT_SCOPES = "openid profile read write";
|
|
22
|
+
const DEFAULT_CLIENT_ID = "open-ai-gateway";
|
|
23
|
+
const DEFAULT_CLIENT_SECRET = "open-ai-gateway-secret";
|
|
24
|
+
const USER_CONFIG_FILE = join(homedir(), ".xiaobao", "config.json");
|
|
25
|
+
function loadUserConfig() {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(USER_CONFIG_FILE, "utf8"));
|
|
28
|
+
} catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function resolveConfig(overrides = {}) {
|
|
33
|
+
const user = loadUserConfig();
|
|
34
|
+
return {
|
|
35
|
+
clientId: DEFAULT_CLIENT_ID,
|
|
36
|
+
clientSecret: DEFAULT_CLIENT_SECRET,
|
|
37
|
+
authBase: overrides.authBase || process.env.XIAOBAO_AUTH_BASE || user.authBase || DEFAULT_AUTH_BASE,
|
|
38
|
+
apiBase: overrides.apiBase || process.env.XIAOBAO_API_BASE || user.apiBase || DEFAULT_API_BASE,
|
|
39
|
+
scopes: overrides.scopes || process.env.XIAOBAO_SCOPES || user.scopes || DEFAULT_SCOPES
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function basicAuth(clientId, clientSecret) {
|
|
43
|
+
return Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/core/device-flow.ts
|
|
47
|
+
/**
|
|
48
|
+
* OAuth 2.0 Device Authorization Grant (RFC 8628) client.
|
|
49
|
+
*
|
|
50
|
+
* Copied verbatim from openclaw-xiaobao/src/core/device-flow.ts — this module
|
|
51
|
+
* is host-agnostic (no OpenClaw imports), so it ports as-is.
|
|
52
|
+
*/
|
|
53
|
+
const POLL_HARD_DEADLINE_MS = 600 * 1e3;
|
|
54
|
+
function authHeaders(config) {
|
|
55
|
+
return {
|
|
56
|
+
Authorization: `Basic ${basicAuth(config.clientId, config.clientSecret)}`,
|
|
57
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
58
|
+
Accept: "application/json"
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
async function postForm(url, headers, body) {
|
|
62
|
+
const resp = await fetch(url, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers,
|
|
65
|
+
body: body.toString()
|
|
66
|
+
});
|
|
67
|
+
const parsed = (resp.headers.get("content-type") || "").includes("application/json") ? await resp.json() : await resp.text();
|
|
68
|
+
if (!resp.ok) throw {
|
|
69
|
+
code: "HTTP_ERROR",
|
|
70
|
+
status: resp.status,
|
|
71
|
+
body: parsed,
|
|
72
|
+
url
|
|
73
|
+
};
|
|
74
|
+
return parsed;
|
|
75
|
+
}
|
|
76
|
+
/** RFC 8628 §3.1 */
|
|
77
|
+
async function initiateDeviceAuthorization(config) {
|
|
78
|
+
const data = await postForm(`${config.authBase}/oauth2/device_authorization`, authHeaders(config), new URLSearchParams({ scope: config.scopes }));
|
|
79
|
+
for (const f of [
|
|
80
|
+
"device_code",
|
|
81
|
+
"user_code",
|
|
82
|
+
"verification_uri",
|
|
83
|
+
"expires_in"
|
|
84
|
+
]) if (data[f] == null) throw {
|
|
85
|
+
code: "DEVICE_AUTH_BAD_RESPONSE",
|
|
86
|
+
message: `Missing field '${f}' in device_authorization response`,
|
|
87
|
+
body: data
|
|
88
|
+
};
|
|
89
|
+
return data;
|
|
90
|
+
}
|
|
91
|
+
/** RFC 8628 §3.4 — poll the token endpoint until success / denial / expiry. */
|
|
92
|
+
async function pollForToken(config, deviceCode, intervalSeconds, expiresInSeconds) {
|
|
93
|
+
const serverDeadline = Date.now() + expiresInSeconds * 1e3;
|
|
94
|
+
const hardDeadline = Date.now() + POLL_HARD_DEADLINE_MS;
|
|
95
|
+
let pollIntervalMs = Math.max(1, intervalSeconds) * 1e3;
|
|
96
|
+
while (Date.now() < Math.min(serverDeadline, hardDeadline)) {
|
|
97
|
+
await sleep(pollIntervalMs);
|
|
98
|
+
const url = `${config.authBase}/oauth2/token`;
|
|
99
|
+
const body = new URLSearchParams({
|
|
100
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
101
|
+
device_code: deviceCode
|
|
102
|
+
});
|
|
103
|
+
const resp = await fetch(url, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: authHeaders(config),
|
|
106
|
+
body: body.toString()
|
|
107
|
+
});
|
|
108
|
+
const parsed = await resp.json();
|
|
109
|
+
if (resp.ok) return parsed;
|
|
110
|
+
const err = parsed.error;
|
|
111
|
+
if (err === "authorization_pending") continue;
|
|
112
|
+
if (err === "slow_down") {
|
|
113
|
+
pollIntervalMs += 5e3;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (err === "access_denied") throw {
|
|
117
|
+
code: "ACCESS_DENIED",
|
|
118
|
+
message: "User declined authorization",
|
|
119
|
+
body: parsed
|
|
120
|
+
};
|
|
121
|
+
if (err === "expired_token") throw {
|
|
122
|
+
code: "EXPIRED_TOKEN",
|
|
123
|
+
message: "Device code expired before user approved",
|
|
124
|
+
body: parsed
|
|
125
|
+
};
|
|
126
|
+
throw {
|
|
127
|
+
code: "TOKEN_POLL_FAILED",
|
|
128
|
+
status: resp.status,
|
|
129
|
+
body: parsed
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
throw {
|
|
133
|
+
code: "TIMEOUT",
|
|
134
|
+
message: "Device authorization expired before completion",
|
|
135
|
+
expires_in: expiresInSeconds
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** RFC 6749 §6 */
|
|
139
|
+
async function refreshToken(config, refresh) {
|
|
140
|
+
const data = await postForm(`${config.authBase}/oauth2/token`, authHeaders(config), new URLSearchParams({
|
|
141
|
+
grant_type: "refresh_token",
|
|
142
|
+
refresh_token: refresh
|
|
143
|
+
}));
|
|
144
|
+
if (!data.refresh_token) data.refresh_token = refresh;
|
|
145
|
+
return data;
|
|
146
|
+
}
|
|
147
|
+
/** RFC 7009 — best-effort refresh-token revocation. */
|
|
148
|
+
async function revokeRefreshToken(config, refresh) {
|
|
149
|
+
try {
|
|
150
|
+
return (await fetch(`${config.authBase}/oauth2/revoke`, {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers: authHeaders(config),
|
|
153
|
+
body: new URLSearchParams({
|
|
154
|
+
token: refresh,
|
|
155
|
+
token_type_hint: "refresh_token"
|
|
156
|
+
}).toString()
|
|
157
|
+
})).ok;
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function sleep(ms) {
|
|
163
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
164
|
+
}
|
|
165
|
+
//#endregion
|
|
166
|
+
export { resolveConfig as a, revokeRefreshToken as i, pollForToken as n, refreshToken as r, initiateDeviceAuthorization as t };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
//#region src/commands/focus/index.ts
|
|
3
|
+
var focus_default = defineCommand({
|
|
4
|
+
meta: {
|
|
5
|
+
name: "focus",
|
|
6
|
+
description: "客户关注点标签分页查询"
|
|
7
|
+
},
|
|
8
|
+
subCommands: { list: () => import("./list-CVKSGNu4.mjs").then((m) => m.default) }
|
|
9
|
+
});
|
|
10
|
+
//#endregion
|
|
11
|
+
export { focus_default as default };
|