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
@@ -0,0 +1,420 @@
1
+ import { register } from './index.mjs'
2
+ import { usageError } from '../errors.mjs'
3
+ import { collectQuery, parseNumber } from '../query.mjs'
4
+ import { flagEnum, flagString, flagNumber, flagBool, payloadFrom, camelCase } from '../payload.mjs'
5
+ import { requireReason } from '../sensitive.mjs'
6
+ import { runWithVersionRetry, newRequestId } from '../idempotent.mjs'
7
+ import { toShanghaiOffset } from '../time.mjs'
8
+ import { idParam, textParam } from '../path.mjs'
9
+
10
+ /** planInputFromFlags 的 flag 清单:常量声明,flag 词表守护测试才扫得到内联字面量以外的名字 */
11
+ const PLAN_TIME_FLAGS = ['opens-at', 'closes-at', 'publish-at']
12
+ const DURATION_FLAGS = ['survey-seconds', 'coding-seconds', 'reflection-seconds']
13
+
14
+ /**
15
+ * requestId 优先级:重试下发的新 id > --request-id > -d 里的 requestId > 新生成。
16
+ * 「重试下发的 id」排第一是有意的:runWithVersionRetry 只在自动重试时给出取值,
17
+ * 首次请求给 null,此时才轮到显式 --request-id。若把显式 id 放在最前,版本冲突重试
18
+ * 会复用同一个 id,而重试 body 里的 version 已经变了——后端 replay() 判定
19
+ * 「同 requestId + 不同 body」直接抛 IDEMPOTENCY_CONFLICT,自动重试于是永远失败。
20
+ * 早先的 `{ requestId: ..., ...base }` 还会让 -d 反向盖掉 --request-id。
21
+ * 带自动重试的写命令由 runWithVersionRetry 统一签发 id,因此 -d 里写 requestId
22
+ * 在这些命令上不再生效——幂等只保留一个来源,比「看哪个 flag 后写」更可预测。
23
+ * @param {object} ctx
24
+ * @param {object} base
25
+ * @param {string|null} [issued] runWithVersionRetry 下发的 id(仅重试时非空)
26
+ */
27
+ function withRequestId(ctx, base, issued = null) {
28
+ const explicit = flagString(ctx, 'request-id')
29
+ return { ...base, requestId: issued || explicit || base.requestId || newRequestId() }
30
+ }
31
+
32
+ /** 列表查询参数(page/pageSize + 各资源特有筛选) */
33
+ function listQuery(ctx, extra = {}) {
34
+ const query = collectQuery(ctx, { page: { parse: parseNumber }, 'page-size': { key: 'pageSize', parse: parseNumber }, ...extra })
35
+ return query
36
+ }
37
+
38
+ /**
39
+ * AI Coding 的 groupIds 必须保持「字符串数组」:后端 AiCodingValidation.ids()
40
+ * 对非 textual 元素直接 400,再逐个 Long.parseLong。
41
+ * 但原来的 `.filter(Boolean)` 会把空段静默丢掉(`1,,3` 变成两个),改成显式拒绝。
42
+ */
43
+ function parseGroupIds(raw) {
44
+ return String(raw).split(',').map((part) => {
45
+ const text = part.trim()
46
+ if (!/^[1-9]\d*$/.test(text)) {
47
+ throw usageError(`--group-ids 含无效 id:${text === '' ? '(空段)' : text}`, '用逗号分隔的正整数,如 --group-ids 1,2,3;不要留空段或尾随逗号')
48
+ }
49
+ return text
50
+ })
51
+ }
52
+
53
+ /* ============================ ai plans ============================ */
54
+
55
+ const PLAN_ACTIONS = new Set(['list', 'get', 'create', 'update', 'publish', 'withdraw', 'starts', 'publication', 'credential'])
56
+
57
+ function planInputFromFlags(ctx) {
58
+ const base = {}
59
+ const name = flagString(ctx, 'name', { required: true, label: 'ch ai plans create --name 计划名' })
60
+ base.name = name
61
+ const groupIds = flagString(ctx, 'group-ids', { required: true, label: '--group-ids 1,2' })
62
+ base.groupIds = parseGroupIds(groupIds)
63
+ for (const flag of PLAN_TIME_FLAGS) {
64
+ const value = flagString(ctx, flag)
65
+ base[camelCase(flag)] = value === undefined ? null : toShanghaiOffset(value)
66
+ }
67
+ const reflectionMode = flagEnum(ctx, 'reflection-mode', ['STATIC', 'PERSONALIZED'])
68
+ if (reflectionMode !== undefined) base.reflectionMode = reflectionMode
69
+ const durations = {}
70
+ for (const flag of DURATION_FLAGS) {
71
+ const value = flagNumber(ctx, flag)
72
+ if (value !== undefined) durations[camelCase(flag)] = value
73
+ }
74
+ if (Object.keys(durations).length > 0) base.durations = durations
75
+ return payloadFrom(ctx, base)
76
+ }
77
+
78
+ register('ai plans', {
79
+ summary: 'AI Coding 计划:list/get/create/update/publish/withdraw/starts/publication/credential',
80
+ // 只有 AI Coding 端点读 body.requestId 并去重,因此 --request-id 仅在这些命令上有意义
81
+ requestId: true,
82
+ usage: [
83
+ 'ch ai plans list [--page N] [--page-size N] [--status DRAFT|PUBLISHED] [--group-id N]',
84
+ 'ch ai plans get <id>',
85
+ 'ch ai plans create --name 名 --group-ids 1,2 [--opens-at "..."] [--closes-at "..."] [--publish-at "..."] [--survey-seconds N] [--coding-seconds N] [--reflection-seconds N] [--reflection-mode STATIC|PERSONALIZED] [-d @plan.json]',
86
+ 'ch ai plans update <id> [...同 create...] [--version N] # version 缺省自动重读',
87
+ 'ch ai plans publish <id>',
88
+ 'ch ai plans withdraw <id> --reason 原因',
89
+ 'ch ai plans starts <id> --paused true|false --reason 原因',
90
+ 'ch ai plans publication <id> --publish-at "..." --reason 原因',
91
+ 'ch ai plans credential <id> <modelId> --api-key sk-... --reason 原因',
92
+ ].join('\n '),
93
+ endpoints: [
94
+ 'GET /admin/ai-coding/plans',
95
+ 'POST /admin/ai-coding/plans',
96
+ 'GET /admin/ai-coding/plans/{id}',
97
+ 'PUT /admin/ai-coding/plans/{id}',
98
+ 'POST /admin/ai-coding/plans/{id}/publish',
99
+ 'POST /admin/ai-coding/plans/{id}/withdraw',
100
+ 'PATCH /admin/ai-coding/plans/{id}/starts',
101
+ 'PATCH /admin/ai-coding/plans/{id}/publication',
102
+ 'PUT /admin/ai-coding/plans/{id}/models/{modelId}/credential',
103
+ ],
104
+ run: async (ctx) => {
105
+ const [action, ...rest] = ctx.positionals
106
+ if (!PLAN_ACTIONS.has(action)) {
107
+ throw usageError(`ai plans 子命令必须是 ${[...PLAN_ACTIONS].join('|')}`, '用法:ch help ai plans')
108
+ }
109
+ const id = textParam(rest[0], { label: 'id', allowEmpty: true })
110
+
111
+ if (action === 'list') {
112
+ const { data } = await ctx.client.request({
113
+ method: 'GET',
114
+ path: '/admin/ai-coding/plans',
115
+ query: listQuery(ctx, { status: { key: 'status' }, 'group-id': { key: 'groupId' } }),
116
+ })
117
+ return data
118
+ }
119
+ if (action === 'get') {
120
+ if (!id) throw usageError('缺少计划 id', '用法:ch ai plans get <id>')
121
+ const { data } = await ctx.client.request({ method: 'GET', path: `/admin/ai-coding/plans/${id}` })
122
+ return data
123
+ }
124
+ if (action === 'create') {
125
+ const input = planInputFromFlags(ctx)
126
+ const { data } = await ctx.client.request({
127
+ method: 'POST',
128
+ path: '/admin/ai-coding/plans',
129
+ body: withRequestId(ctx, input),
130
+ })
131
+ return data
132
+ }
133
+ if (action === 'update') {
134
+ if (!id) throw usageError('缺少计划 id', '用法:ch ai plans update <id>')
135
+ const input = planInputFromFlags(ctx)
136
+ return runWithVersionRetry({
137
+ explicitVersion: flagNumber(ctx, 'version'),
138
+ explicitRequestId: flagString(ctx, 'request-id'),
139
+ readVersion: async () => {
140
+ const { data } = await ctx.client.request({ method: 'GET', path: `/admin/ai-coding/plans/${id}` })
141
+ if (typeof data?.version !== 'number') throw usageError('计划详情中没有 version')
142
+ return data.version
143
+ },
144
+ log: (m) => ctx.log(m),
145
+ run: async ({ requestId, version }) => {
146
+ const { data } = await ctx.client.request({
147
+ method: 'PUT',
148
+ path: `/admin/ai-coding/plans/${id}`,
149
+ body: withRequestId(ctx, { ...input, version }, requestId),
150
+ })
151
+ return data
152
+ },
153
+ })
154
+ }
155
+ if (action === 'publish') {
156
+ if (!id) throw usageError('缺少计划 id', '用法:ch ai plans publish <id>')
157
+ return runWithVersionRetry({
158
+ explicitVersion: flagNumber(ctx, 'version'),
159
+ explicitRequestId: flagString(ctx, 'request-id'),
160
+ readVersion: async () => {
161
+ const { data } = await ctx.client.request({ method: 'GET', path: `/admin/ai-coding/plans/${id}` })
162
+ if (typeof data?.version !== 'number') throw usageError('计划详情中没有 version')
163
+ return data.version
164
+ },
165
+ log: (m) => ctx.log(m),
166
+ run: async ({ requestId, version }) => {
167
+ const { data } = await ctx.client.request({
168
+ method: 'POST',
169
+ path: `/admin/ai-coding/plans/${id}/publish`,
170
+ body: withRequestId(ctx, { version }, requestId),
171
+ })
172
+ return data
173
+ },
174
+ })
175
+ }
176
+ if (action === 'withdraw') {
177
+ if (!id) throw usageError('缺少计划 id', '用法:ch ai plans withdraw <id> --reason 原因')
178
+ const reason = requireReason(ctx, { label: 'ch ai plans withdraw <id> --reason 原因' })
179
+ return runWithVersionRetry({
180
+ explicitVersion: flagNumber(ctx, 'version'),
181
+ explicitRequestId: flagString(ctx, 'request-id'),
182
+ readVersion: async () => {
183
+ const { data } = await ctx.client.request({ method: 'GET', path: `/admin/ai-coding/plans/${id}` })
184
+ if (typeof data?.version !== 'number') throw usageError('计划详情中没有 version')
185
+ return data.version
186
+ },
187
+ log: (m) => ctx.log(m),
188
+ run: async ({ requestId, version }) => {
189
+ const { data } = await ctx.client.request({
190
+ method: 'POST',
191
+ path: `/admin/ai-coding/plans/${id}/withdraw`,
192
+ body: withRequestId(ctx, { version, reason }, requestId),
193
+ })
194
+ return data
195
+ },
196
+ })
197
+ }
198
+ if (action === 'starts') {
199
+ if (!id) throw usageError('缺少计划 id', '用法:ch ai plans starts <id> --paused true --reason 原因')
200
+ const paused = flagBool(ctx, 'paused')
201
+ if (paused === undefined) throw usageError('缺少 --paused', '用法:ch ai plans starts <id> --paused true|false --reason 原因')
202
+ const reason = requireReason(ctx, { label: '--reason 原因' })
203
+ return runWithVersionRetry({
204
+ explicitVersion: flagNumber(ctx, 'version'),
205
+ explicitRequestId: flagString(ctx, 'request-id'),
206
+ readVersion: async () => {
207
+ const { data } = await ctx.client.request({ method: 'GET', path: `/admin/ai-coding/plans/${id}` })
208
+ if (typeof data?.version !== 'number') throw usageError('计划详情中没有 version')
209
+ return data.version
210
+ },
211
+ log: (m) => ctx.log(m),
212
+ run: async ({ requestId, version }) => {
213
+ const { data } = await ctx.client.request({
214
+ method: 'PATCH',
215
+ path: `/admin/ai-coding/plans/${id}/starts`,
216
+ body: withRequestId(ctx, { version, paused, reason }, requestId),
217
+ })
218
+ return data
219
+ },
220
+ })
221
+ }
222
+ if (action === 'publication') {
223
+ if (!id) throw usageError('缺少计划 id', '用法:ch ai plans publication <id> --publish-at "..." --reason 原因')
224
+ const publishAt = flagString(ctx, 'publish-at', { required: true, label: '--publish-at "2026-09-29 09:00"' })
225
+ const reason = requireReason(ctx, { label: '--reason 原因' })
226
+ return runWithVersionRetry({
227
+ explicitVersion: flagNumber(ctx, 'version'),
228
+ explicitRequestId: flagString(ctx, 'request-id'),
229
+ readVersion: async () => {
230
+ const { data } = await ctx.client.request({ method: 'GET', path: `/admin/ai-coding/plans/${id}` })
231
+ if (typeof data?.version !== 'number') throw usageError('计划详情中没有 version')
232
+ return data.version
233
+ },
234
+ log: (m) => ctx.log(m),
235
+ run: async ({ requestId, version }) => {
236
+ const { data } = await ctx.client.request({
237
+ method: 'PATCH',
238
+ path: `/admin/ai-coding/plans/${id}/publication`,
239
+ body: withRequestId(ctx, { version, publishAt: toShanghaiOffset(publishAt), reason }, requestId),
240
+ })
241
+ return data
242
+ },
243
+ })
244
+ }
245
+ // action === 'credential'
246
+ const modelId = rest[1]
247
+ if (!modelId) throw usageError('缺少 modelId', '用法:ch ai plans credential <id> <modelId> --api-key sk-... --reason 原因')
248
+ const apiKey = flagString(ctx, 'api-key', { required: true, label: '--api-key sk-...' })
249
+ const reason = requireReason(ctx, { label: '--reason 轮换原因' })
250
+ return runWithVersionRetry({
251
+ explicitVersion: flagNumber(ctx, 'key-version'),
252
+ explicitRequestId: flagString(ctx, 'request-id'),
253
+ readVersion: async () => {
254
+ const { data } = await ctx.client.request({ method: 'GET', path: `/admin/ai-coding/plans/${id}` })
255
+ const model = Array.isArray(data?.models) ? data.models.find((m) => String(m.id) === String(modelId)) : null
256
+ if (!model || typeof model.keyVersion !== 'number') {
257
+ throw usageError(`计划 ${id} 中找不到模型 ${modelId} 的 keyVersion,可用 --key-version 显式指定`)
258
+ }
259
+ return model.keyVersion
260
+ },
261
+ log: (m) => ctx.log(m),
262
+ run: async ({ requestId, version }) => {
263
+ const { data } = await ctx.client.request({
264
+ method: 'PUT',
265
+ path: `/admin/ai-coding/plans/${id}/models/${textParam(modelId, { label: 'modelId' })}/credential`,
266
+ body: withRequestId(ctx, { keyVersion: version, apiKey, reason }, requestId),
267
+ })
268
+ return data
269
+ },
270
+ })
271
+ },
272
+ })
273
+
274
+ /* ============================ ai attempts ============================ */
275
+
276
+ const ATTEMPT_ACTIONS = new Set(['list', 'get', 'grade', 'void'])
277
+
278
+ register('ai attempts', {
279
+ summary: 'AI Coding 考次:list/get/grade/void',
280
+ requestId: true,
281
+ usage: [
282
+ 'ch ai attempts list [--page N] [--page-size N] [--plan-id N] [--group-id N] [--state SURVEY|CODING|REFLECTION|SUBMITTED|VOIDED] [--grade-state UNGRADED|HIDDEN|PUBLISHED|VOIDED] [--keyword 关键词]',
283
+ 'ch ai attempts get <id>',
284
+ 'ch ai attempts grade <id> --score 85 --public-comment 公开评价 --internal-comment 内部评价 [--reason 修正原因]',
285
+ 'ch ai attempts void <id> --reason 原因',
286
+ ].join('\n '),
287
+ endpoints: [
288
+ 'GET /admin/ai-coding/attempts',
289
+ 'GET /admin/ai-coding/attempts/{id}',
290
+ 'PUT /admin/ai-coding/attempts/{id}/grade',
291
+ 'POST /admin/ai-coding/attempts/{id}/void',
292
+ ],
293
+ run: async (ctx) => {
294
+ const [action, ...rest] = ctx.positionals
295
+ if (!ATTEMPT_ACTIONS.has(action)) {
296
+ throw usageError(`ai attempts 子命令必须是 ${[...ATTEMPT_ACTIONS].join('|')}`, '用法:ch help ai attempts')
297
+ }
298
+ const id = textParam(rest[0], { label: 'id', allowEmpty: true })
299
+
300
+ if (action === 'list') {
301
+ const { data } = await ctx.client.request({
302
+ method: 'GET',
303
+ path: '/admin/ai-coding/attempts',
304
+ query: listQuery(ctx, {
305
+ 'plan-id': { key: 'planId' },
306
+ 'group-id': { key: 'groupId' },
307
+ state: { key: 'state' },
308
+ 'grade-state': { key: 'gradeState' },
309
+ keyword: { key: 'keyword' },
310
+ }),
311
+ })
312
+ return data
313
+ }
314
+ if (action === 'get') {
315
+ if (!id) throw usageError('缺少考次 id', '用法:ch ai attempts get <id>')
316
+ const { data } = await ctx.client.request({ method: 'GET', path: `/admin/ai-coding/attempts/${id}` })
317
+ return data
318
+ }
319
+ if (action === 'grade') {
320
+ if (!id) throw usageError('缺少考次 id', '用法:ch ai attempts grade <id> --score 85 --public-comment ...')
321
+ const score = flagNumber(ctx, 'score', { required: true, label: '--score 0-100 整数' })
322
+ if (!Number.isInteger(score) || score < 0 || score > 100) throw usageError('--score 必须是 0-100 的整数')
323
+ const publicComment = flagString(ctx, 'public-comment', { required: true, label: '--public-comment 考生可见评价' })
324
+ const internalComment = flagString(ctx, 'internal-comment', { required: true, label: '--internal-comment 内部评价' })
325
+ const reason = flagString(ctx, 'reason')
326
+ return runWithVersionRetry({
327
+ explicitVersion: flagNumber(ctx, 'expected-revision'),
328
+ explicitRequestId: flagString(ctx, 'request-id'),
329
+ readVersion: async () => {
330
+ const { data } = await ctx.client.request({ method: 'GET', path: `/admin/ai-coding/attempts/${id}` })
331
+ if (typeof data?.attempt?.version !== 'number') throw usageError('考次详情中没有 attempt.version')
332
+ return data.attempt.version
333
+ },
334
+ log: (m) => ctx.log(m),
335
+ run: async ({ requestId, version }) => {
336
+ const base = { expectedRevision: version, score, publicComment, internalComment }
337
+ if (reason !== undefined) base.reason = reason
338
+ const { data } = await ctx.client.request({
339
+ method: 'PUT',
340
+ path: `/admin/ai-coding/attempts/${id}/grade`,
341
+ body: withRequestId(ctx, payloadFrom(ctx, base), requestId),
342
+ })
343
+ return data
344
+ },
345
+ })
346
+ }
347
+ // action === 'void'
348
+ if (!id) throw usageError('缺少考次 id', '用法:ch ai attempts void <id> --reason 原因')
349
+ const reason = requireReason(ctx, { label: 'ch ai attempts void <id> --reason 原因' })
350
+ return runWithVersionRetry({
351
+ explicitVersion: flagNumber(ctx, 'version'),
352
+ explicitRequestId: flagString(ctx, 'request-id'),
353
+ readVersion: async () => {
354
+ const { data } = await ctx.client.request({ method: 'GET', path: `/admin/ai-coding/attempts/${id}` })
355
+ if (typeof data?.attempt?.version !== 'number') throw usageError('考次详情中没有 attempt.version')
356
+ return data.attempt.version
357
+ },
358
+ log: (m) => ctx.log(m),
359
+ run: async ({ requestId, version }) => {
360
+ const { data } = await ctx.client.request({
361
+ method: 'POST',
362
+ path: `/admin/ai-coding/attempts/${id}/void`,
363
+ body: withRequestId(ctx, { version, reason }, requestId),
364
+ })
365
+ return data
366
+ },
367
+ })
368
+ },
369
+ })
370
+
371
+ /* ============================ ai candidates ============================ */
372
+
373
+ const CANDIDATE_ACTIONS = new Set(['list', 'retake'])
374
+
375
+ register('ai candidates', {
376
+ summary: 'AI Coding 考生资格:list/retake(安排重考)',
377
+ requestId: true,
378
+ usage: [
379
+ 'ch ai candidates list [--page N] [--page-size N] [--plan-id N] [--group-id N] [--eligibility NOT_REQUIRED|WAITING_SELECTION|INELIGIBLE|EXEMPT|REQUIRED] [--keyword 关键词]',
380
+ 'ch ai candidates retake <userId> --plan-id N --assignment-version N --opens-at "..." --closes-at "..." --reason 原因',
381
+ ].join('\n '),
382
+ endpoints: ['GET /admin/ai-coding/candidates', 'POST /admin/ai-coding/candidates/{userId}/retake'],
383
+ run: async (ctx) => {
384
+ const [action, ...rest] = ctx.positionals
385
+ if (!CANDIDATE_ACTIONS.has(action)) {
386
+ throw usageError(`ai candidates 子命令必须是 ${[...CANDIDATE_ACTIONS].join('|')}`, '用法:ch help ai candidates')
387
+ }
388
+ if (action === 'list') {
389
+ const { data } = await ctx.client.request({
390
+ method: 'GET',
391
+ path: '/admin/ai-coding/candidates',
392
+ query: listQuery(ctx, {
393
+ 'plan-id': { key: 'planId' },
394
+ 'group-id': { key: 'groupId' },
395
+ eligibility: { key: 'eligibility' },
396
+ keyword: { key: 'keyword' },
397
+ }),
398
+ })
399
+ return data
400
+ }
401
+ const userId = idParam(rest[0], { label: 'userId' })
402
+ const planId = flagString(ctx, 'plan-id', { required: true, label: '--plan-id N' })
403
+ const assignmentVersion = flagNumber(ctx, 'assignment-version', { required: true, label: '--assignment-version N(取自 ch ai candidates list 的 assignmentVersion)' })
404
+ const opensAt = flagString(ctx, 'opens-at', { required: true, label: '--opens-at "2026-09-29 10:00"' })
405
+ const closesAt = flagString(ctx, 'closes-at', { required: true, label: '--closes-at "2026-09-29 12:00"' })
406
+ const reason = requireReason(ctx, { label: '--reason 重考原因' })
407
+ const { data } = await ctx.client.request({
408
+ method: 'POST',
409
+ path: `/admin/ai-coding/candidates/${userId}/retake`,
410
+ body: withRequestId(ctx, {
411
+ planId,
412
+ assignmentVersion,
413
+ opensAt: toShanghaiOffset(opensAt),
414
+ closesAt: toShanghaiOffset(closesAt),
415
+ reason,
416
+ }),
417
+ })
418
+ return data
419
+ },
420
+ })
@@ -0,0 +1,124 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import { register } from './index.mjs'
5
+ import { CliError, EXIT, usageError } from '../errors.mjs'
6
+ import { flagString, flagNumber, flagOn, parseDataJson, parseBoolFlag } from '../payload.mjs'
7
+ import { idParam } from '../path.mjs'
8
+ const resources = JSON.parse(fs.readFileSync(new URL('../analysis-resources.json', import.meta.url), 'utf8'))
9
+
10
+ const specs = new Map(resources.map(resource => [resource.name, resource]))
11
+ const requirement = 'backend v36 data-analysis permission (CLI 0.3.0)'
12
+ const read = { permission: 'data-analysis', backendRequirement: requirement }
13
+ function resource(ctx) {
14
+ const name = ctx.positionals[0]
15
+ if (!specs.has(name)) throw usageError('未知分析资源', '运行 ch analysis resources 查看资源目录')
16
+ return name
17
+ }
18
+ function query(ctx, name) {
19
+ const size = flagNumber(ctx, 'size') ?? 100
20
+ if (!Number.isInteger(size) || size < 1 || size > 500) throw usageError('--size 须为 1–500 的整数')
21
+ const raw = flagString(ctx, 'filters')
22
+ const filters = raw === undefined ? {} : parseDataJson(raw, { label: '--filters' })
23
+ if (!filters || typeof filters !== 'object' || Array.isArray(filters) || Object.keys(filters).length > 16) throw usageError('--filters 必须是最多 16 个字段的对象')
24
+ for (const [key, value] of Object.entries(filters)) {
25
+ const declared = specs.get(name).filters
26
+ const type = Object.hasOwn(declared, key) ? declared[key] : undefined
27
+ if (!type || (type === 'integer' && (!Number.isSafeInteger(value) || value < 0)) ||
28
+ (type === 'boolean' && typeof value !== 'boolean') ||
29
+ (type === 'string' && (typeof value !== 'string' || value.length > 200))) throw usageError(`不适用的过滤字段或取值:${key}`)
30
+ }
31
+ const cursor = flagString(ctx, 'cursor')
32
+ if (cursor && (!/^[A-Za-z0-9_-]+$/.test(cursor) || cursor.length > 2048)) throw usageError('--cursor 无效')
33
+ return [['size', size], ['filters', JSON.stringify(filters)], ['cursor', cursor]]
34
+ }
35
+ function destination(ctx) {
36
+ const out = path.resolve(flagString(ctx, 'out', { required: true }))
37
+ try { if (!fs.statSync(path.dirname(out)).isDirectory()) throw Error() } catch { throw usageError('--out 父目录不存在') }
38
+ if (fs.existsSync(out) && (!flagOn(ctx, 'force') || !fs.lstatSync(out).isFile())) throw usageError('目标已存在或不是普通文件;覆盖普通文件需 --force')
39
+ return out
40
+ }
41
+ function publish(temp, out, force) {
42
+ // link is atomic and refuses a race-created target. --force uses atomic replacement.
43
+ if (force) fs.renameSync(temp, out)
44
+ else { fs.linkSync(temp, out); fs.unlinkSync(temp) }
45
+ }
46
+ function responseError(message) { return new CliError({ code: 'UNEXPECTED_RESPONSE', message, exitCode: EXIT.BUSINESS }) }
47
+
48
+ register('analysis resources', {
49
+ ...read, summary: '查询全量只读数据目录、字段、过滤条件及实际后端可用性', usage: 'ch analysis resources',
50
+ endpoints: ['GET /admin/analysis/resources'],
51
+ run: async ctx => (await ctx.client.request({ path: '/admin/analysis/resources' })).data,
52
+ })
53
+ register('analysis list', {
54
+ ...read, summary: '按主键游标查询全量业务数据(不推进业务状态)',
55
+ usage: 'ch analysis list <resource> [--cursor 游标] [--size 100] [--filters JSON或@文件]',
56
+ endpoints: ['GET /admin/analysis/resources/{resource}'],
57
+ run: async ctx => { const name = resource(ctx); return (await ctx.client.request({ path: `/admin/analysis/resources/${name}`, query: query(ctx, name) })).data },
58
+ })
59
+ register('analysis get', {
60
+ ...read, summary: '读取单条分析记录,包括题目、答卷和评价(按字段白名单)',
61
+ usage: 'ch analysis get <resource> <id>', endpoints: ['GET /admin/analysis/resources/{resource}/{id}'],
62
+ run: async ctx => { const name = resource(ctx); const id = idParam(ctx.positionals[1]); if (BigInt(id) < 1n) throw usageError('id 须为正整数'); return (await ctx.client.request({ path: `/admin/analysis/resources/${name}/${id}` })).data },
63
+ })
64
+ register('analysis export', {
65
+ ...read, localWrite: true, summary: '逐页导出 JSONL,全部成功后原子落盘;多页不是同一时刻快照',
66
+ usage: 'ch analysis export <resource> --out ./data.jsonl [--size 100] [--filters JSON或@文件] [--force]',
67
+ endpoints: ['GET /admin/analysis/resources/{resource}'],
68
+ run: async ctx => {
69
+ const name = resource(ctx), args = query(ctx, name), out = destination(ctx)
70
+ const temp = `${out}.partial-${crypto.randomUUID()}`
71
+ const fd = fs.openSync(temp, 'wx', 0o600)
72
+ let count = 0, pages = 0, upperBound = null, cursor, last = 0n
73
+ const seen = new Set()
74
+ try {
75
+ do {
76
+ const { data } = await ctx.client.request({ path: `/admin/analysis/resources/${name}`, query: args.filter(([key]) => key !== 'cursor').concat([['cursor', cursor]]) })
77
+ if (!data || data.resource !== name || !Array.isArray(data.items) || !/^\d+$/.test(String(data.upperBound)) ||
78
+ (data.nextCursor !== null && (typeof data.nextCursor !== 'string' || !/^[A-Za-z0-9_-]+$/.test(data.nextCursor)))) throw responseError('分析分页响应无效')
79
+ if (upperBound !== null && upperBound !== data.upperBound) throw responseError('导出期间分页上界改变')
80
+ upperBound = data.upperBound
81
+ for (const row of data.items) {
82
+ const id = String(row?.[specs.get(name).key])
83
+ if (!/^\d+$/.test(id) || BigInt(id) <= last || BigInt(id) > BigInt(upperBound)) throw responseError('分析分页记录顺序或主键无效')
84
+ last = BigInt(id); fs.writeSync(fd, JSON.stringify(row) + '\n'); count++
85
+ }
86
+ pages++; cursor = data.nextCursor
87
+ if (cursor && (seen.has(cursor) || data.items.length === 0)) throw responseError('分析分页游标重复或没有进展')
88
+ if (cursor) seen.add(cursor)
89
+ } while (cursor)
90
+ fs.fsyncSync(fd); fs.closeSync(fd); publish(temp, out, flagOn(ctx, 'force'))
91
+ } catch (error) {
92
+ try { fs.closeSync(fd) } catch {}
93
+ try { fs.unlinkSync(temp) } catch {}
94
+ throw error
95
+ }
96
+ return { savedTo: out, resource: name, records: count, pages, upperBound, consistency: 'bounded-live-read; not a transaction snapshot across pages' }
97
+ },
98
+ })
99
+ register('analysis download', {
100
+ ...read, localWrite: true, summary: '受鉴权下载分析附件到本地;不支持任意 URL 或服务器路径',
101
+ usage: 'ch analysis download <attachmentId> --out ./attachment [--force]',
102
+ endpoints: ['GET /admin/analysis/attachments/{attachmentId}'],
103
+ run: async ctx => {
104
+ const id = ctx.positionals[0]
105
+ if (!/^(?:ai-[a-f0-9]{32}\.[a-z0-9]{2,5}|plan-[1-9]\d*-(?:assessment-file|assessment-image|pass-image|fail-image))$/.test(id ?? '')) throw usageError('attachmentId 无效,请使用分析记录中的 analysisAttachments.id')
106
+ const out = destination(ctx)
107
+ const { buffer, contentType } = await ctx.client.request({ path: `/admin/analysis/attachments/${id}`, raw: true })
108
+ const temp = `${out}.partial-${crypto.randomUUID()}`
109
+ try { fs.writeFileSync(temp, buffer, { flag: 'wx', mode: 0o600 }); publish(temp, out, flagOn(ctx, 'force')) }
110
+ catch (error) { try { fs.unlinkSync(temp) } catch {}; throw error }
111
+ return { savedTo: out, bytes: buffer.length, contentType }
112
+ },
113
+ })
114
+ register('accounts analysis-permission', {
115
+ permission: 'SYSTEM', backendRequirement: requirement, summary: 'SYSTEM 开启/关闭管理员数据分析权限(版本冲突须刷新)',
116
+ usage: 'ch accounts analysis-permission <id> --enabled true|false --version N',
117
+ endpoints: ['PUT /admin/accounts/{id}/data-analysis-permission'],
118
+ run: async ctx => {
119
+ const id = idParam(ctx.positionals[0]), enabled = parseBoolFlag(flagString(ctx, 'enabled', { required: true }), { flag: 'enabled' })
120
+ const version = flagNumber(ctx, 'version', { required: true })
121
+ if (!Number.isSafeInteger(version) || version < 0) throw usageError('--version 须为非负整数')
122
+ return (await ctx.client.request({ method: 'PUT', path: `/admin/accounts/${id}/data-analysis-permission`, body: { enabled, version } })).data
123
+ },
124
+ })
@@ -0,0 +1,63 @@
1
+ import { register } from './index.mjs'
2
+ import { usageError } from '../errors.mjs'
3
+ import { toArray } from '../argv.mjs'
4
+ import { parseDataJson, flagOn } from '../payload.mjs'
5
+
6
+ const METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])
7
+
8
+ /**
9
+ * 通用兜底:调用任意 API 端点。
10
+ * 语义化命令未覆盖的长尾端点、新上线端点均可用它触达,保证 CLI 与后端能力同步。
11
+ */
12
+ register('api', {
13
+ summary: '通用兜底:调用任意 API 端点',
14
+ usage: 'ch api <METHOD> <path> [-q key=value ...] [-d <json|@file>] [-H "Name: value" ...] [--public]',
15
+ endpoints: [],
16
+ // 逃生舱:未被 CLI 语义化的新端点参数也必须能发出去,所以跳过未知 flag 与 arity 检查
17
+ permissiveFlags: true,
18
+ run: async (ctx) => {
19
+ const [methodRaw, requestPath] = ctx.positionals
20
+ if (!methodRaw || !requestPath) {
21
+ throw usageError('缺少 METHOD 或 path', '用法:ch api GET /admin/dashboard')
22
+ }
23
+ const method = String(methodRaw).toUpperCase()
24
+ if (!METHODS.has(method)) {
25
+ throw usageError(`不支持的 HTTP 方法:${methodRaw}`, `可用:${[...METHODS].join(' / ')}`)
26
+ }
27
+
28
+ const query = []
29
+ for (const entry of toArray(ctx.multi.get('q'))) {
30
+ const text = String(entry)
31
+ const idx = text.indexOf('=')
32
+ const key = idx >= 0 ? text.slice(0, idx) : text
33
+ const value = idx >= 0 ? text.slice(idx + 1) : ''
34
+ if (key) query.push([key, value])
35
+ }
36
+
37
+ const headers = {}
38
+ for (const entry of toArray(ctx.multi.get('H'))) {
39
+ const text = String(entry)
40
+ const idx = text.indexOf(':')
41
+ if (idx <= 0) throw usageError(`请求头格式应为 "Name: value":${text}`)
42
+ headers[text.slice(0, idx).trim()] = text.slice(idx + 1).trim()
43
+ }
44
+
45
+ let body
46
+ const rawBody = ctx.flags.get('d')
47
+ if (rawBody !== undefined && rawBody !== '') {
48
+ body = parseDataJson(rawBody, { label: '-d' })
49
+ }
50
+
51
+ const { data } = await ctx.client.request({
52
+ method,
53
+ path: requestPath,
54
+ query,
55
+ body,
56
+ headers,
57
+ noAuth: flagOn(ctx, 'public'),
58
+ // 逃生舱:允许把查询串直接写在 path 里(语义化命令不允许,见 client.mjs buildUrl)
59
+ allowQuery: true,
60
+ })
61
+ return data
62
+ },
63
+ })