cloudhouse-admin-cli 0.3.3

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 (96) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +21 -0
  3. package/README.md +63 -0
  4. package/SECURITY.md +11 -0
  5. package/THIRD_PARTY_NOTICES.md +7 -0
  6. package/bin/ch.mjs +11 -0
  7. package/docs/00-overview.md +71 -0
  8. package/docs/01-command-reference.md +1206 -0
  9. package/docs/02-agent-contract.md +118 -0
  10. package/docs/03-agent-setup.md +69 -0
  11. package/docs/04-audit.md +3 -0
  12. package/docs/05-release.md +5 -0
  13. package/docs/06-coverage.md +3 -0
  14. package/docs/07-data-analysis.md +105 -0
  15. package/docs/08-installation.md +36 -0
  16. package/docs/security-review.md +26 -0
  17. package/integrations/codex/.agents/plugins/marketplace.json +20 -0
  18. package/integrations/codex/.codex-plugin/plugin.json +10 -0
  19. package/integrations/codex/LICENSE +21 -0
  20. package/integrations/codex/README.md +5 -0
  21. package/integrations/codex/package.json +7 -0
  22. package/integrations/codex/plugin.json +11 -0
  23. package/integrations/codex/skills/cloudhouse-admin/SKILL.md +32 -0
  24. package/integrations/codex/skills/cloudhouse-admin/references/01-command-reference.md +1206 -0
  25. package/integrations/codex/skills/cloudhouse-admin/references/02-agent-contract.md +118 -0
  26. package/integrations/dsh/LICENSE +21 -0
  27. package/integrations/dsh/README.md +14 -0
  28. package/integrations/dsh/index.mjs +7 -0
  29. package/integrations/dsh/package.json +12 -0
  30. package/integrations/dsh/skills/cloudhouse-admin/SKILL.md +32 -0
  31. package/integrations/dsh/skills/cloudhouse-admin/references/01-command-reference.md +1206 -0
  32. package/integrations/dsh/skills/cloudhouse-admin/references/02-agent-contract.md +118 -0
  33. package/integrations/openclaw/LICENSE +21 -0
  34. package/integrations/openclaw/README.md +5 -0
  35. package/integrations/openclaw/package.json +7 -0
  36. package/integrations/openclaw/skills/cloudhouse-admin/SKILL.md +32 -0
  37. package/integrations/openclaw/skills/cloudhouse-admin/references/01-command-reference.md +1206 -0
  38. package/integrations/openclaw/skills/cloudhouse-admin/references/02-agent-contract.md +118 -0
  39. package/integrations/opencode/LICENSE +21 -0
  40. package/integrations/opencode/README.md +5 -0
  41. package/integrations/opencode/package.json +7 -0
  42. package/integrations/opencode/skills/cloudhouse-admin/SKILL.md +32 -0
  43. package/integrations/opencode/skills/cloudhouse-admin/references/01-command-reference.md +1206 -0
  44. package/integrations/opencode/skills/cloudhouse-admin/references/02-agent-contract.md +118 -0
  45. package/integrations/pi/LICENSE +21 -0
  46. package/integrations/pi/README.md +5 -0
  47. package/integrations/pi/package.json +15 -0
  48. package/integrations/pi/skills/cloudhouse-admin/SKILL.md +32 -0
  49. package/integrations/pi/skills/cloudhouse-admin/references/01-command-reference.md +1206 -0
  50. package/integrations/pi/skills/cloudhouse-admin/references/02-agent-contract.md +118 -0
  51. package/package.json +44 -0
  52. package/skills/cloudhouse-admin/SKILL.md +32 -0
  53. package/skills/cloudhouse-admin/references/01-command-reference.md +1206 -0
  54. package/skills/cloudhouse-admin/references/02-agent-contract.md +118 -0
  55. package/src/agent-install.mjs +50 -0
  56. package/src/analysis-resources.json +1207 -0
  57. package/src/argv.mjs +88 -0
  58. package/src/capabilities.mjs +18 -0
  59. package/src/cli.mjs +180 -0
  60. package/src/client.mjs +169 -0
  61. package/src/commands/accounts.mjs +115 -0
  62. package/src/commands/agent.mjs +13 -0
  63. package/src/commands/ai.mjs +420 -0
  64. package/src/commands/analysis.mjs +124 -0
  65. package/src/commands/api.mjs +63 -0
  66. package/src/commands/applies.mjs +89 -0
  67. package/src/commands/assistant.mjs +53 -0
  68. package/src/commands/auth.mjs +109 -0
  69. package/src/commands/candidates.mjs +252 -0
  70. package/src/commands/checkin.mjs +41 -0
  71. package/src/commands/dashboard.mjs +11 -0
  72. package/src/commands/evaluations.mjs +27 -0
  73. package/src/commands/extended.mjs +96 -0
  74. package/src/commands/feishu.mjs +31 -0
  75. package/src/commands/groups.mjs +66 -0
  76. package/src/commands/index.mjs +101 -0
  77. package/src/commands/interviews.mjs +151 -0
  78. package/src/commands/load.mjs +27 -0
  79. package/src/commands/notifications.mjs +131 -0
  80. package/src/commands/plans.mjs +90 -0
  81. package/src/commands/profile.mjs +43 -0
  82. package/src/commands/slots.mjs +95 -0
  83. package/src/commands/uploads.mjs +169 -0
  84. package/src/config.mjs +68 -0
  85. package/src/errors.mjs +156 -0
  86. package/src/flags.mjs +114 -0
  87. package/src/idempotent.mjs +75 -0
  88. package/src/output.mjs +138 -0
  89. package/src/path.mjs +40 -0
  90. package/src/payload.mjs +137 -0
  91. package/src/prompt.mjs +47 -0
  92. package/src/query.mjs +44 -0
  93. package/src/sensitive.mjs +50 -0
  94. package/src/session.mjs +94 -0
  95. package/src/time.mjs +87 -0
  96. package/src/version.mjs +1 -0
package/src/argv.mjs ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * argv 解析:位置参数 + --flag(支持 --flag=value / --flag value / --no-flag)。
3
+ * 重复出现的 -q / -H / -d 等多值 flag 通过 multi 取值。
4
+ */
5
+ import { BOOL_FLAGS } from './flags.mjs'
6
+
7
+ /** 布尔开关后面只有在跟的是布尔字面量时才把它当取值,否则 `ch --pretty dashboard` 会吃掉命令名 */
8
+ const BOOL_LITERALS = new Set(['true', 'false', '1', '0', 'yes', 'no'])
9
+
10
+ function nextIsValue(token, name) {
11
+ if (token === undefined || token === '-' || token.startsWith('-')) return false
12
+ return !BOOL_FLAGS.has(name) || BOOL_LITERALS.has(token.toLowerCase())
13
+ }
14
+
15
+ export function parseArgs(argv) {
16
+ const positionals = []
17
+ const flags = new Map()
18
+ const multi = new Map()
19
+
20
+ for (let i = 0; i < argv.length; i += 1) {
21
+ const token = argv[i]
22
+ if (token === '--') {
23
+ positionals.push(...argv.slice(i + 1))
24
+ break
25
+ }
26
+ if (token.startsWith('--')) {
27
+ const body = token.slice(2)
28
+ const eq = body.indexOf('=')
29
+ let name = eq >= 0 ? body.slice(0, eq) : body
30
+ let value
31
+ if (eq >= 0) {
32
+ value = body.slice(eq + 1)
33
+ } else if (name.startsWith('no-')) {
34
+ name = name.slice(3)
35
+ value = false
36
+ } else {
37
+ value = nextIsValue(argv[i + 1], name) ? argv[++i] : true
38
+ }
39
+ flags.set(name, value)
40
+ const bucket = multi.get(name) || []
41
+ bucket.push(value)
42
+ multi.set(name, bucket)
43
+ continue
44
+ }
45
+ if (token.startsWith('-') && token.length > 1 && !/^-\d/.test(token)) {
46
+ // 单字母短选项:-q value / -q=value / -qVALUE / -q(布尔)
47
+ const shortMatch = token.match(/^-([A-Za-z])(?:=([\s\S]*))?$/)
48
+ if (shortMatch) {
49
+ const name = shortMatch[1]
50
+ let value = shortMatch[2]
51
+ if (value === undefined) {
52
+ const next = argv[i + 1]
53
+ if (next !== undefined && next !== '-' && !next.startsWith('-')) {
54
+ value = next
55
+ i += 1
56
+ } else {
57
+ value = true
58
+ }
59
+ }
60
+ flags.set(name, value)
61
+ const bucket = multi.get(name) || []
62
+ bucket.push(value)
63
+ multi.set(name, bucket)
64
+ continue
65
+ }
66
+ // 其余按布尔簇处理(如 -abc)
67
+ for (const ch of token.slice(1)) {
68
+ flags.set(ch, true)
69
+ const bucket = multi.get(ch) || []
70
+ bucket.push(true)
71
+ multi.set(ch, bucket)
72
+ }
73
+ continue
74
+ }
75
+ positionals.push(token)
76
+ }
77
+
78
+ return { positionals, flags, multi }
79
+ }
80
+
81
+ export function str(value) {
82
+ return typeof value === 'string' ? value : undefined
83
+ }
84
+
85
+ export function toArray(value) {
86
+ if (value === undefined) return []
87
+ return Array.isArray(value) ? value : [value]
88
+ }
@@ -0,0 +1,18 @@
1
+ import { getRegistry } from './commands/index.mjs'
2
+ import { supportedFlags, usageLines } from './flags.mjs'
3
+ import { VERSION } from './version.mjs'
4
+
5
+ export function capabilities() {
6
+ return { version: VERSION, schemaVersion: 1, defaultProfile: 'local', productionBaseUrl: 'https://api.dayunwu.cn/api', commands: [...getRegistry().values()].map(spec => ({
7
+ name: spec.name, summary: spec.summary, usage: spec.usage,
8
+ flags: [...supportedFlags(spec)].sort(), endpoints: spec.endpoints || [],
9
+ effect: spec.name === 'api' ? 'dynamic' : (spec.endpoints || []).some(e => !e.startsWith('GET ')) ? 'write' : spec.localWrite ? 'local-write' : 'read',
10
+ permission: spec.permission || (spec.auth === false ? 'public-or-local' : 'backend-admin-authorization'),
11
+ requestId: Boolean(spec.requestId), backendRequirement: spec.backendRequirement || null,
12
+ actions: usageLines(spec).length > 1 ? usageLines(spec).map(line => {
13
+ const action = line.slice(`ch ${spec.name} `.length).split(' ')[0]
14
+ const read = ['list', 'get', 'get-generation'].includes(action)
15
+ return { name: action, effect: read ? 'read' : 'write', flags: [...supportedFlags(spec, [action])].sort(), requestId: Boolean(spec.requestId && !read) }
16
+ }) : undefined,
17
+ })) }
18
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,180 @@
1
+ import { EXIT, CliError, usageError } from './errors.mjs'
2
+ import { resolveConfig } from './config.mjs'
3
+ import * as sessionStore from './session.mjs'
4
+ import { createClient, DEFAULT_TIMEOUT_MS } from './client.mjs'
5
+ import { printSuccess, printFailure } from './output.mjs'
6
+ import { parseArgs } from './argv.mjs'
7
+ import { checkFlags } from './flags.mjs'
8
+ import { flagOn } from './payload.mjs'
9
+ import { resolveCommand, renderHelp } from './commands/index.mjs'
10
+ import './commands/load.mjs'
11
+ import { VERSION } from './version.mjs'
12
+
13
+ /** --timeout <ms>:1s ~ 10min,越界是用法错误而不是一个静默生效的怪值 */
14
+ function resolveTimeoutMs(flags) {
15
+ const raw = flags.get('timeout')
16
+ if (raw === undefined) return DEFAULT_TIMEOUT_MS
17
+ const ms = Number(raw)
18
+ if (!Number.isInteger(ms) || ms < 1000 || ms > 600_000) {
19
+ throw usageError('--timeout 必须是 1000 到 600000 之间的整数毫秒', '默认 30000ms;注意超时不等于后端没执行')
20
+ }
21
+ return ms
22
+ }
23
+
24
+ function isOn(value) {
25
+ if (value === undefined) return undefined
26
+ if (typeof value === 'string') return !['false', '0', 'no'].includes(value.toLowerCase())
27
+ return value === true
28
+ }
29
+
30
+ /**
31
+ * `--no-pretty` 在 argv 里落成了 pretty=false,用 Map.has() 判定会把它读成「要求表格」,
32
+ * 于是 Agent 显式要 JSON 却拿到人类表格(JSON.parse 直接失败)。必须按取值判定真假。
33
+ */
34
+ function shouldPretty(flags, stdout) {
35
+ const pretty = isOn(flags.get('pretty'))
36
+ if (pretty === true) return true
37
+ if (isOn(flags.get('json')) === true) return false
38
+ if (pretty === false) return false
39
+ // TTY 默认人类可读表格;管道/重定向(Agent 调用)默认 JSON
40
+ return Boolean(stdout && stdout.isTTY)
41
+ }
42
+
43
+ /**
44
+ * CLI 入口。
45
+ * @param {string[]} argv 不含 node 与脚本名
46
+ * @param {NodeJS.ProcessEnv} env
47
+ * @param {{stdout?:NodeJS.WritableStream, stderr?:NodeJS.WritableStream}} io
48
+ * @returns {Promise<number>} exit code
49
+ */
50
+ export async function run(argv, env = process.env, io = {}) {
51
+ const stdout = io.stdout || process.stdout
52
+ const stderr = io.stderr || process.stderr
53
+ const { positionals, flags, multi } = parseArgs(argv)
54
+ const pretty = shouldPretty(flags, stdout)
55
+
56
+ try {
57
+ if ((positionals.length === 0 && flags.get('version') === true) || (positionals.length === 1 && positionals[0] === 'version')) {
58
+ printSuccess(stdout, { name: 'cloudhouse-admin-cli', version: VERSION }, { pretty })
59
+ return EXIT.OK
60
+ }
61
+ // `ch candidates list --help` 的目标就是 positionals 本身;早先无条件 slice(1)
62
+ // 会把命令词切掉,只剩 ['list'] → 打印「未找到命令:list」还 return 0。
63
+ const helpRequested = flags.get('help') !== undefined && flags.get('help') !== false
64
+ if (helpRequested || positionals.length === 0 || positionals[0] === 'help') {
65
+ const target = helpRequested ? positionals : positionals[0] === 'help' ? positionals.slice(1) : []
66
+ const help = renderHelp(target)
67
+ stdout.write(`${help.text}\n`)
68
+ if (help.missing) {
69
+ throw usageError(`未找到命令:${target.join(' ')}`, '运行 ch help 查看全部命令')
70
+ }
71
+ return EXIT.OK
72
+ }
73
+
74
+ const globals = {
75
+ pretty,
76
+ profile: typeof flags.get('profile') === 'string' ? flags.get('profile') : undefined,
77
+ baseUrl: typeof flags.get('base-url') === 'string' ? flags.get('base-url') : undefined,
78
+ }
79
+ const command = resolveCommand(positionals)
80
+ if (!command) {
81
+ throw usageError(`未知命令:${positionals.join(' ')}`, '运行 ch help 查看全部命令')
82
+ }
83
+ const { key, spec, args } = command
84
+ // 只有 backend/aicoding 读 body.requestId 并做去重,其它端点的审计 requestId 由服务端
85
+ // 自己生成(AdminInterviewAuditContext)。过去非 AI 命令把它静默丢弃,而文档承诺
86
+ // 「所有写操作幂等」,Agent 照文档重放超时/5xx 的写就会重复录入——静默接受比报错更危险。
87
+ if (flags.get('request-id') !== undefined && spec.requestId !== true) {
88
+ throw usageError(
89
+ '--request-id 只被 ch ai * 的写操作支持',
90
+ '其余端点不读取该字段,重放保护请依赖幂等的读接口或在应用层去重;直接去掉 --request-id 即可执行',
91
+ )
92
+ }
93
+ for (const warning of checkFlags({ entries: [...flags.entries()], spec, positionals: args })) {
94
+ stderr.write(`警告:${warning}\n`)
95
+ }
96
+ const config = resolveConfig({ flags: globals, env })
97
+
98
+ const savedSession = spec.auth === false ? null : sessionStore.loadSession(env)
99
+ const envToken = typeof env.CH_TOKEN === 'string' ? env.CH_TOKEN.trim() : ''
100
+ let token = envToken || (savedSession && savedSession.token) || null
101
+
102
+ // 「会话文件坏了」和「从没登录过」的处置方式完全不同:前者修文件,后者只需要 login。
103
+ // 过去两者都报 NOT_LOGGED_IN,用户反复登录也反复被同一个坏文件挡住。
104
+ if (!token && spec.auth !== false) {
105
+ const damage = sessionStore.describeSessionDamage(env)
106
+ if (damage) {
107
+ throw new CliError({
108
+ code: 'SESSION_DAMAGED',
109
+ message: `本地会话文件不可用:${damage.file}(${damage.reason})`,
110
+ exitCode: EXIT.AUTH,
111
+ hint: '确认没有其它凭据来源后可删除该文件再执行 ch login;删除会丢弃当前会话',
112
+ })
113
+ }
114
+ }
115
+ // 会话只有一份 session.json,不按 profile 分文件:`ch login --profile prod` 之后
116
+ // 裸跑命令会默认 local,把生产 token 发到本地(或反过来)。这里比对登录时记录的
117
+ // baseUrl,不一致默认拒绝;--allow-profile-mismatch 用于确实要跨环境的场景。
118
+ if (!envToken && token && savedSession && typeof savedSession.baseUrl === 'string' && savedSession.baseUrl !== config.baseUrl) {
119
+ if (flagOn({ flags }, 'allow-profile-mismatch')) {
120
+ stderr.write(`提示:已用 --allow-profile-mismatch 放行跨环境会话。\n`)
121
+ } else {
122
+ throw usageError(`本地会话来自 ${savedSession.baseUrl},目标为 ${config.baseUrl};已阻止跨环境发送 Bearer Token`, '重新登录目标环境,或确认后显式加 --allow-profile-mismatch')
123
+ }
124
+ }
125
+
126
+ const client = createClient({
127
+ baseUrl: config.baseUrl,
128
+ timeoutMs: resolveTimeoutMs(flags),
129
+ // 未登录延迟到真正要发请求时才判定:命令体的本地参数校验在此之前完成,
130
+ // 用法错误(2)才不会被鉴权错误(3)掩盖——否则 `ch candidates list --page abc`
131
+ // 在未登录时永远只报「请先登录」,调用方看不到 --page 才是错的。
132
+ getAuthHeaders: () => {
133
+ if (!token) {
134
+ throw new CliError({
135
+ code: 'NOT_LOGGED_IN',
136
+ message: '尚未登录:未找到本地会话(~/.config/cloudhouse-cli/session.json)且未设置 CH_TOKEN',
137
+ exitCode: EXIT.AUTH,
138
+ hint: '先执行 ch login,或为 Agent 场景设置 CH_TOKEN 环境变量',
139
+ })
140
+ }
141
+ return { Authorization: `Bearer ${token}` }
142
+ },
143
+ onRenewal: (sentToken, renewedToken, expiresAt) => {
144
+ if (token === sentToken) token = renewedToken
145
+ try {
146
+ sessionStore.absorbRenewal(env, sentToken, renewedToken, expiresAt)
147
+ } catch {
148
+ /* 续期落盘失败不阻断命令;下次仍可用旧 token 直至绝对上限 */
149
+ }
150
+ },
151
+ })
152
+
153
+ const ctx = {
154
+ key,
155
+ client,
156
+ config,
157
+ flags,
158
+ multi,
159
+ positionals: args,
160
+ pretty,
161
+ env,
162
+ session: savedSession,
163
+ token,
164
+ stdout,
165
+ stderr,
166
+ log: (message) => stderr.write(`${message}\n`),
167
+ setToken: (next) => {
168
+ token = next
169
+ },
170
+ }
171
+
172
+ const data = await spec.run(ctx)
173
+ printSuccess(stdout, data, { pretty })
174
+ return EXIT.OK
175
+ } catch (err) {
176
+ printFailure(stdout, stderr, err)
177
+ if (err instanceof CliError) return err.exitCode
178
+ return EXIT.BUSINESS
179
+ }
180
+ }
package/src/client.mjs ADDED
@@ -0,0 +1,169 @@
1
+ import { CliError, classifyFailure, usageError, EXIT } from './errors.mjs'
2
+
3
+ const RENEWED_TOKEN_HEADER = 'X-Renewed-Token'
4
+ const TOKEN_EXPIRES_AT_HEADER = 'X-Token-Expires-At'
5
+
6
+ /**
7
+ * 没有超时的 fetch 会让 CLI 永久阻塞,对 Agent 流水线是致命的:它无法按退出码分支,
8
+ * 只能被上层 kill 掉。普通请求 30s,multipart 上传放宽到 120s(大附件本来就慢)。
9
+ */
10
+ export const DEFAULT_TIMEOUT_MS = 30_000
11
+ export const UPLOAD_TIMEOUT_MS = 120_000
12
+
13
+ /**
14
+ * 封装与后端的全部 HTTP 交互。
15
+ *
16
+ * @param {object} options
17
+ * @param {string} options.baseUrl API 根地址(不含路径),如 http://127.0.0.1:8080/api
18
+ * @param {() => Record<string,string>} options.getAuthHeaders 每次请求时取当前鉴权头(支持续期后换 token)
19
+ * @param {(sentToken:string, renewedToken:string, expiresAtMs:number|null) => void} [options.onRenewal]
20
+ */
21
+ export function createClient({ baseUrl, getAuthHeaders, onRenewal, timeoutMs = DEFAULT_TIMEOUT_MS }) {
22
+ /**
23
+ * 路径形状守门:位置参数是裸插值进 path 模板的,`ch candidates get "42?foo=bar"`
24
+ * 实测发出 /users/42?foo=bar/info——new URL() 把尾随的 /info 当成了查询串的值,
25
+ * 请求被静默改形还照旧带上 JWT 打向真实端点。空格 / # \ 与 `..` 同理。
26
+ * 只有 `ch api` 允许在路径里直接写查询串(它就是逃生舱)。
27
+ */
28
+ function buildUrl(requestPath, query, { allowQuery = false } = {}) {
29
+ if (typeof requestPath !== 'string' || !requestPath.startsWith('/')) {
30
+ throw new CliError({
31
+ code: 'INVALID_PATH',
32
+ message: `接口路径必须以 / 开头:${requestPath}`,
33
+ exitCode: EXIT.USAGE,
34
+ })
35
+ }
36
+ const [pathPart, ...rest] = requestPath.split('?')
37
+ if (rest.length > 0 && !allowQuery) {
38
+ throw usageError(`接口路径里不能带查询串:${requestPath}`, '查询参数请用 -q k=v(ch api)或对应的语义 flag')
39
+ }
40
+ if (/[#\s]|\.\./.test(pathPart)) {
41
+ throw usageError(`接口路径含未编码的字符:${requestPath}`, '路径参数中的空格、# 与 .. 会改变请求形状;数字 id 请直接给数字')
42
+ }
43
+ const url = new URL(`${baseUrl.replace(/\/+$/, '')}${requestPath}`)
44
+ if (query) {
45
+ for (const [key, value] of query) {
46
+ if (value !== undefined && value !== null && value !== '') url.searchParams.append(key, String(value))
47
+ }
48
+ }
49
+ return url.toString()
50
+ }
51
+
52
+ function absorbRenewal(response, authHeader) {
53
+ if (!onRenewal || !authHeader) return
54
+ const sentToken = authHeader.replace(/^Bearer\s+/i, '')
55
+ const renewed = response.headers.get(RENEWED_TOKEN_HEADER)
56
+ if (!renewed || renewed === sentToken) return
57
+ const rawExpires = response.headers.get(TOKEN_EXPIRES_AT_HEADER)
58
+ const expiresAt = rawExpires !== null && Number.isFinite(Number(rawExpires)) ? Number(rawExpires) : null
59
+ onRenewal(sentToken, renewed, expiresAt)
60
+ }
61
+
62
+ /**
63
+ * 发起一次请求。
64
+ * @param {object} request
65
+ * @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} [request.method]
66
+ * @param {string} request.path 以 / 开头
67
+ * @param {Array<[string, string|number]>} [request.query] 查询参数
68
+ * @param {unknown} [request.body] 普通 JSON 载荷
69
+ * @param {FormData} [request.formData] multipart 载荷(与 body 互斥)
70
+ * @param {Record<string,string>} [request.headers] 额外请求头
71
+ * @param {boolean} [request.noAuth] 公开端点:不携带 Authorization
72
+ * @param {boolean} [request.raw] 二进制响应模式:返回 Buffer 而非解析 JSON
73
+ * @param {boolean} [request.allowQuery] 允许 path 里直接写查询串(仅 ch api)
74
+ * @returns {Promise<{httpStatus:number, data:unknown}>}
75
+ */
76
+ async function request({ method = 'GET', path: requestPath, query, body, formData, headers = {}, noAuth = false, raw = false, allowQuery = false }) {
77
+ const authHeaders = noAuth ? {} : getAuthHeaders()
78
+ const url = buildUrl(requestPath, query, { allowQuery })
79
+
80
+ const requestHeaders = { Accept: 'application/json', ...authHeaders, ...headers }
81
+ let payload
82
+ if (formData !== undefined) {
83
+ payload = formData
84
+ } else if (body !== undefined) {
85
+ requestHeaders['Content-Type'] = 'application/json'
86
+ payload = JSON.stringify(body)
87
+ }
88
+
89
+ let response
90
+ const limit = formData !== undefined ? Math.max(timeoutMs, UPLOAD_TIMEOUT_MS) : timeoutMs
91
+ try {
92
+ response = await fetch(url, { method, headers: requestHeaders, body: payload, signal: AbortSignal.timeout(limit), redirect: 'error' })
93
+ } catch (err) {
94
+ const timedOut = err && (err.name === 'TimeoutError' || err.name === 'AbortError')
95
+ if (timedOut) {
96
+ throw new CliError({
97
+ code: 'NETWORK_TIMEOUT',
98
+ message: `请求超时(${limit}ms):${method} ${url}`,
99
+ exitCode: EXIT.NETWORK,
100
+ hint: '超时不等于没执行:写操作可能已经在后端生效。先用读接口确认结果,不要直接重放;确需放宽用 --timeout <ms>',
101
+ })
102
+ }
103
+ throw new CliError({
104
+ code: 'NETWORK_UNREACHABLE',
105
+ message: `无法连接后端服务(${url}):${err && err.message ? err.message : err}`,
106
+ exitCode: EXIT.NETWORK,
107
+ hint: '请检查 baseUrl(--base-url / CH_BASE_URL / ch profile)、网络与后端进程',
108
+ })
109
+ }
110
+
111
+ absorbRenewal(response, requestHeaders.Authorization)
112
+
113
+ let envelope = null
114
+ const binary = raw ? Buffer.from(await response.arrayBuffer()) : null
115
+ const rawJson = raw && /(?:application\/json|\+json)/i.test(response.headers.get('Content-Type') || '')
116
+ if (!raw || rawJson) {
117
+ const text = raw ? binary.toString('utf8') : await response.text()
118
+ if (text) {
119
+ try {
120
+ envelope = JSON.parse(text)
121
+ } catch {
122
+ envelope = null
123
+ }
124
+ }
125
+ }
126
+
127
+ const envelopeCode = envelope && typeof envelope.code === 'number' ? envelope.code : null
128
+ if (response.ok && !raw && response.status !== 204 && (envelopeCode === null || !envelope || Array.isArray(envelope))) {
129
+ throw new CliError({ code: 'UNEXPECTED_RESPONSE', message: `接口返回无效 JSON 信封(HTTP ${response.status})`, exitCode: EXIT.BUSINESS, hint: '检查 API 根地址和代理路由;这不是成功响应,写操作结果未知时先查询确认' })
130
+ }
131
+ if (response.ok && raw && (/text\/html/i.test(response.headers.get('Content-Type') || '') || rawJson && (envelopeCode === null || envelopeCode === 200))) {
132
+ throw new CliError({ code: 'UNEXPECTED_RESPONSE', message: '附件接口返回 HTML 或 JSON,未保存文件', exitCode: EXIT.BUSINESS })
133
+ }
134
+ if (response.ok && (envelopeCode === null || envelopeCode === 200)) {
135
+ if (raw) {
136
+ return {
137
+ httpStatus: response.status,
138
+ contentType: response.headers.get('Content-Type'),
139
+ contentDisposition: response.headers.get('Content-Disposition'),
140
+ buffer: binary,
141
+ }
142
+ }
143
+ return { httpStatus: response.status, data: envelope ? (envelope.data ?? null) : null }
144
+ }
145
+
146
+ // 反向代理/网关异常时可能返回 HTML:统一转成结构化错误
147
+ if (!envelope) {
148
+ throw classifyFailure({
149
+ httpStatus: response.status,
150
+ code: response.status,
151
+ message: `请求失败(HTTP ${response.status},非 JSON 响应)`,
152
+ payload: null,
153
+ retryAfter: response.headers.get('Retry-After'),
154
+ requestPath,
155
+ })
156
+ }
157
+
158
+ throw classifyFailure({
159
+ httpStatus: response.status,
160
+ code: envelopeCode ?? response.status,
161
+ message: typeof envelope.message === 'string' && envelope.message ? envelope.message : `请求失败(HTTP ${response.status})`,
162
+ payload: envelope.data ?? null,
163
+ retryAfter: response.headers.get('Retry-After'),
164
+ requestPath,
165
+ })
166
+ }
167
+
168
+ return { request, get baseUrl() { return baseUrl.replace(/\/+$/, '') } }
169
+ }
@@ -0,0 +1,115 @@
1
+ import { register } from './index.mjs'
2
+ import { usageError } from '../errors.mjs'
3
+ import { flagEnum, flagString, flagNumber, payloadFrom, parseIdList } from '../payload.mjs'
4
+ import { idParam } from '../path.mjs'
5
+
6
+ const ADMIN_LEVELS = ['SUPER', 'REGULAR', 'INTERVIEWER']
7
+
8
+ function validateStudentNo(studentNo) {
9
+ if (!/^\d{10}$/.test(studentNo)) {
10
+ throw usageError('学号必须是 10 位数字', '与招新系统学号规则一致')
11
+ }
12
+ }
13
+
14
+ function validatePassword(password) {
15
+ if (typeof password !== 'string' || password.length < 6) {
16
+ throw usageError('密码长度不能少于 6 位')
17
+ }
18
+ }
19
+
20
+ register('accounts list', {
21
+ summary: '管理员账号列表',
22
+ usage: 'ch accounts list [--pretty]',
23
+ endpoints: ['GET /admin/accounts'],
24
+ run: async (ctx) => {
25
+ const { data } = await ctx.client.request({ method: 'GET', path: '/admin/accounts' })
26
+ return data
27
+ },
28
+ })
29
+
30
+ register('accounts create', {
31
+ summary: '创建分级管理员账号',
32
+ usage: 'ch accounts create --studentNo 10位学号 --password 密码 [--real-name 姓名] --admin-level SUPER|REGULAR|INTERVIEWER [--authorized-group-ids 1,2]',
33
+ endpoints: ['POST /admin/accounts'],
34
+ run: async (ctx) => {
35
+ const studentNo = flagString(ctx, 'studentNo', { required: true, label: 'ch accounts create --studentNo 2026000001' })
36
+ validateStudentNo(studentNo)
37
+ const password = flagString(ctx, 'password', { required: true, label: 'ch accounts create --password ******' })
38
+ validatePassword(password)
39
+ const adminLevel = flagEnum(ctx, 'admin-level', ADMIN_LEVELS, { required: true, label: `--admin-level ${ADMIN_LEVELS.join('|')}` })
40
+ const base = { studentNo, password, adminLevel }
41
+ const realName = flagString(ctx, 'real-name')
42
+ if (realName !== undefined) base.realName = realName
43
+ const groupIds = flagString(ctx, 'authorized-group-ids')
44
+ if (groupIds !== undefined) base.authorizedGroupIds = parseIdList(groupIds, { flag: 'authorized-group-ids' })
45
+ const payload = payloadFrom(ctx, base)
46
+ const { data } = await ctx.client.request({ method: 'POST', path: '/admin/accounts', body: payload })
47
+ return data
48
+ },
49
+ })
50
+
51
+ register('accounts batch', {
52
+ summary: '批量创建同级别管理员账号',
53
+ usage: 'ch accounts batch --count N --password 初始密码 --admin-level SUPER|REGULAR|INTERVIEWER [--authorized-group-ids 1,2]',
54
+ endpoints: ['POST /admin/accounts/batch'],
55
+ run: async (ctx) => {
56
+ const count = flagNumber(ctx, 'count', { required: true, label: 'ch accounts batch --count 5' })
57
+ if (!Number.isInteger(count) || count <= 0) throw usageError('--count 必须是正整数')
58
+ const password = flagString(ctx, 'password', { required: true, label: 'ch accounts batch --password ******' })
59
+ validatePassword(password)
60
+ const adminLevel = flagEnum(ctx, 'admin-level', ADMIN_LEVELS, { required: true, label: `--admin-level ${ADMIN_LEVELS.join('|')}` })
61
+ const base = { count, password, adminLevel }
62
+ const groupIds = flagString(ctx, 'authorized-group-ids')
63
+ if (groupIds !== undefined) base.authorizedGroupIds = parseIdList(groupIds, { flag: 'authorized-group-ids' })
64
+ const payload = payloadFrom(ctx, base)
65
+ const { data } = await ctx.client.request({ method: 'POST', path: '/admin/accounts/batch', body: payload })
66
+ return data
67
+ },
68
+ })
69
+
70
+ register('accounts update', {
71
+ summary: '修改管理员资料/级别/授权组别',
72
+ usage: 'ch accounts update <id> [--real-name 姓名] [--admin-level SUPER|REGULAR|INTERVIEWER] [--authorized-group-ids 1,2]',
73
+ endpoints: ['PUT /admin/accounts/{id}'],
74
+ run: async (ctx) => {
75
+ const id = idParam(ctx.positionals[0], { label: '管理员 id' })
76
+ const base = {}
77
+ const realName = flagString(ctx, 'real-name')
78
+ if (realName !== undefined) base.realName = realName
79
+ const adminLevel = flagEnum(ctx, 'admin-level', ADMIN_LEVELS)
80
+ if (adminLevel !== undefined) base.adminLevel = adminLevel
81
+ const groupIds = flagString(ctx, 'authorized-group-ids')
82
+ if (groupIds !== undefined) base.authorizedGroupIds = parseIdList(groupIds, { flag: 'authorized-group-ids' })
83
+ const payload = payloadFrom(ctx, base)
84
+ if (Object.keys(payload).length === 0) {
85
+ throw usageError('至少提供一个修改字段', '--real-name / --admin-level / --authorized-group-ids')
86
+ }
87
+ const { data } = await ctx.client.request({ method: 'PUT', path: `/admin/accounts/${id}`, body: payload })
88
+ return data
89
+ },
90
+ })
91
+
92
+ register('accounts reset-password', {
93
+ summary: '重置管理员密码',
94
+ usage: 'ch accounts reset-password <id> --password 新密码',
95
+ endpoints: ['PUT /admin/accounts/{id}/password'],
96
+ run: async (ctx) => {
97
+ const id = idParam(ctx.positionals[0], { label: '管理员 id' })
98
+ const password = flagString(ctx, 'password', { required: true, label: 'ch accounts reset-password <id> --password ******' })
99
+ validatePassword(password)
100
+ const payload = payloadFrom(ctx, { password })
101
+ const { data } = await ctx.client.request({ method: 'PUT', path: `/admin/accounts/${id}/password`, body: payload })
102
+ return data === null ? { reset: true, id: Number(id) } : data
103
+ },
104
+ })
105
+
106
+ register('accounts delete', {
107
+ summary: '删除管理员账号(初始管理员与自身由后端拒绝)',
108
+ usage: 'ch accounts delete <id>',
109
+ endpoints: ['DELETE /admin/accounts/{id}'],
110
+ run: async (ctx) => {
111
+ const id = idParam(ctx.positionals[0], { label: '管理员 id' })
112
+ const { data } = await ctx.client.request({ method: 'DELETE', path: `/admin/accounts/${id}` })
113
+ return data === null ? { deleted: true, id: Number(id) } : data
114
+ },
115
+ })
@@ -0,0 +1,13 @@
1
+ import { register } from './index.mjs'
2
+ import { capabilities } from '../capabilities.mjs'
3
+ import { installAgent } from '../agent-install.mjs'
4
+ import { flagString } from '../payload.mjs'
5
+ import { usageError } from '../errors.mjs'
6
+ register('capabilities', { summary: '离线机器可读能力目录(含参数、读写、权限、幂等与后端要求)', usage: 'ch capabilities [--json]', auth: false, endpoints: [], run: async () => capabilities() })
7
+ register('agent install', {
8
+ summary: '为 agent 安装原生技能/插件(仅本地,不含凭据)', usage: 'ch agent install <agent> [--scope user|project]', auth: false, localWrite: true, endpoints: [],
9
+ run: async ctx => {
10
+ if (ctx.positionals.length !== 1) throw usageError('用法:ch agent install <codex|dsh|opencode|openclaw|pi> --scope user|project')
11
+ return installAgent(ctx.positionals[0], flagString(ctx, 'scope') || 'user', ctx.env)
12
+ },
13
+ })