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/flags.mjs ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * flag 词表:`argv.mjs` 只做机械切分,本模块负责「认不认识这个 flag」与「它该不该有值」。
3
+ *
4
+ * 为什么显式写表而不是运行时扫源码:启动不依赖文件系统、表本身可读可 grep,
5
+ * 代价(新加 flag 必须登记)由 `test/flags.test.mjs` 双向核对来付——
6
+ * 漏登记与陈旧登记都会被测试拦下,不会退化成又一次静默丢失。
7
+ *
8
+ * 0.2.0 参数策略:
9
+ * VALUE_FLAGS 收到布尔(即 `--flag` 后面没有取值,或取值以 `-` 开头)→ exit 2;
10
+ * 未登记、不适用的 flag 与多余位置参数 → 请求前 exit 2;
11
+ * `ch api` 是逃生舱,两个检查都跳过,否则未文档化的新端点参数会被本地拦死。
12
+ */
13
+ import { usageError } from './errors.mjs'
14
+
15
+ /** 必须取值的 flag:丢了取值就等于丢了筛选条件或写进错数据,绝不能静默 */
16
+ export const VALUE_FLAGS = new Set([
17
+ // 全局
18
+ 'profile', 'base-url', 'timeout', 'scope', 'cursor', 'filters', 'enabled',
19
+ // 载荷与请求构造
20
+ 'data', 'd', 'q', 'H', 'reason', 'request-id', 'page', 'size', 'page-size', 'group-id',
21
+ // 账号 / 申请 / 个人资料
22
+ 'studentNo', 'password', 'admin-level', 'real-name', 'authorized-group-ids', 'count',
23
+ 'apply-group-id', 'position-code', 'phone',
24
+ // 候选人
25
+ 'keyword', 'status', 'second-round-group-id', 'third-round-group-id',
26
+ 'third-round-choice-state', 'starred-admin-id', 'starred-by-me', 'group-ids', 'version',
27
+ // 组别 / 方案 / 场次
28
+ 'group-name', 'requirement', 'introduction', 'round', 'scope-group-id', 'assessment-content',
29
+ 'assessment-image-url', 'assessment-file-url', 'assessment-file-name', 'pass-text-content',
30
+ 'pass-image-url', 'fail-text-content', 'fail-image-url', 'booking-start-time', 'booking-end-time',
31
+ 'publish-time', 'publish-mode', 'plan-id', 'slot-time', 'location', 'capacity',
32
+ // 面试 / 评价 / 现场助手
33
+ 'slot', 'slot-id', 'interview-id', 'result', 'remark', 'content', 'score', 'evaluation-content',
34
+ // 通知
35
+ 'title', 'notice-level', 'scope-type', 'target-round', 'target-group-id', 'target-slot-id',
36
+ 'start-time', 'end-time',
37
+ // 上传 / 下载
38
+ 'file', 'name', 'out',
39
+ // 数据型布尔:必须写明确取值。归到取值型而不是开关型,`--starred maybe` 才会
40
+ // 当场报错,而不是把 maybe 甩给位置参数、自己退化成 starred:true。
41
+ 'starred', 'paused',
42
+ // AI Coding
43
+ 'opens-at', 'closes-at', 'publish-at', 'survey-seconds', 'coding-seconds', 'reflection-seconds',
44
+ 'reflection-mode', 'assignment-version', 'expected-revision', 'key-version', 'api-key',
45
+ 'public-comment', 'internal-comment', 'state', 'grade-state', 'eligibility', 'ids',
46
+ // 签到快照
47
+ 'screen-token',
48
+ ])
49
+
50
+ /** 可裸写的开关;带取值时也接受(`--public=false`),取值按 parseBoolFlag 判定 */
51
+ export const BOOL_FLAGS = new Set([
52
+ 'pretty', 'json', 'help', 'public', 'show-token', 'force', 'allow-profile-mismatch', 'all',
53
+ ])
54
+
55
+ export function isKnownFlag(name) {
56
+ return VALUE_FLAGS.has(name) || BOOL_FLAGS.has(name)
57
+ }
58
+
59
+ /** Usage metadata is also the executable parameter contract. */
60
+ const GLOBAL_FLAGS = ['pretty', 'json', 'help', 'profile', 'base-url', 'timeout', 'allow-profile-mismatch']
61
+ const PLAN_FIELDS = ['assessment-content', 'assessment-image-url', 'assessment-file-url', 'assessment-file-name', 'pass-text-content', 'pass-image-url', 'fail-text-content', 'fail-image-url', 'booking-start-time', 'booking-end-time', 'publish-time', 'publish-mode', 'status']
62
+ const AI_FIELDS = ['name', 'group-ids', 'opens-at', 'closes-at', 'publish-at', 'survey-seconds', 'coding-seconds', 'reflection-seconds', 'reflection-mode']
63
+ const EXTRAS = {
64
+ 'plans update': PLAN_FIELDS,
65
+ 'notifications update': ['notice-level', 'target-round', 'target-group-id', 'target-slot-id', 'status'],
66
+ 'ai plans:update': AI_FIELDS,
67
+ 'ai plans:credential': ['key-version'],
68
+ 'ai attempts:grade': ['expected-revision'],
69
+ 'uploads attachment-download': ['force'],
70
+ 'uploads image': ['file'],
71
+ 'uploads assessment': ['file'],
72
+ 'uploads ai-attachment': ['file'],
73
+ }
74
+ export function usageLines(spec) {
75
+ return (Array.isArray(spec.usage) ? spec.usage : String(spec.usage || '').split('\n')).map(s => s.trim())
76
+ }
77
+ function selectedUsage(spec, positionals) {
78
+ const rows = usageLines(spec)
79
+ const prefix = `ch ${spec.name} `
80
+ return rows.find(s => s.startsWith(`${prefix}${positionals[0]} `) || s === `${prefix}${positionals[0]}`) || rows[0] || ''
81
+ }
82
+ export function supportedFlags(spec, positionals = []) {
83
+ const text = positionals.length ? selectedUsage(spec, positionals) : usageLines(spec).join(' ')
84
+ const names = new Set([...GLOBAL_FLAGS, ...(spec.allowedFlags || []), ...(EXTRAS[spec.name] || []), ...(EXTRAS[`${spec.name}:${positionals[0]}`] || [])])
85
+ for (const m of text.matchAll(/(?:^|[\s\[])--?([A-Za-z][\w-]*)/g)) names.add(m[1])
86
+ const action = positionals[0]
87
+ const writes = (spec.endpoints || []).some(e => !e.startsWith('GET ')) && !['list', 'get', 'get-generation'].includes(action)
88
+ const payloadCommand = /^(?:ai (?:plans|attempts|candidates|papers)|(?:plans|groups|slots|notifications|accounts) (?:create|update|batch|reset-password|status)|profile (?:update|change-password)|applies (?:submit|reject)|candidates (?:set-third-round-choice|star|update|application-status|set-second-round-groups|unbind-wechat)|evaluations add|interviews (?:result|attendance|reschedule|batch-result)|assistant submit)$/.test(spec.name)
89
+ if (writes && payloadCommand) { names.add('d'); names.add('data') }
90
+ if (spec.requestId && writes) { names.add('request-id'); names.add('version') }
91
+ return names
92
+ }
93
+ export function expectedPositionals(spec, positionals = []) {
94
+ const rows = spec.name && positionals.length ? [selectedUsage(spec, positionals)] : usageLines(spec)
95
+ return Math.max(0, ...rows.map(line => {
96
+ const clean = line.split(' #')[0].replace(/(^|[\s\[])--?[\w-]+\s+<[^>]+>/g, '$1').replace(/\[[^\]]*\]/g, '')
97
+ const placeholders = (clean.match(/<[^>\s]+>/g) || []).length
98
+ if (!spec.name) return placeholders
99
+ const tail = clean.startsWith(`ch ${spec.name}`) ? clean.slice(`ch ${spec.name}`.length).trim() : ''
100
+ const action = tail && !tail.startsWith('<') && !tail.startsWith('-') ? 1 : 0
101
+ return placeholders + action
102
+ }))
103
+ }
104
+ export function checkFlags({ entries, spec, positionals = [] }) {
105
+ if (spec.permissiveFlags) return []
106
+ const supported = supportedFlags(spec, positionals)
107
+ for (const [name, value] of entries) {
108
+ if (VALUE_FLAGS.has(name) && value === true) throw usageError(`--${name} 需要取值`, `写成 --${name} <值> 或 --${name}=<值>`)
109
+ if (!supported.has(name)) throw usageError(`未识别的 flag 或不适用于 ${spec.name}:--${name}`, '用 ch help <命令> 核对参数;已阻止发送请求')
110
+ }
111
+ const expected = expectedPositionals(spec, positionals)
112
+ if (positionals.length > expected) throw usageError(`${spec.name} 只使用 ${expected} 个位置参数,多余参数已拒绝`, '用 ch help <命令> 核对用法')
113
+ return []
114
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * 幂等与版本重试(对齐 admin-web lib/idempotency.ts 语义):
3
+ * - requestId 只对 `ch ai *` 写操作有意义:全仓只有 backend/aicoding 读 body.requestId 并去重,
4
+ * interviews / plans / slots 等端点的审计 requestId 由服务端自己生成(AdminInterviewAuditContext),
5
+ * 客户端传了也不被读取,所以那些命令上的 --request-id 会被 CLI 直接拒绝而不是静默丢弃。
6
+ * - AI 写操作默认每次生成新 requestId;--request-id 可复用(服务端去重返回首个结果)
7
+ * - 4xx(非 429)为确定性拒绝:换 requestId 视为新操作
8
+ * - VERSION_CONFLICT / GRADE_VERSION_CONFLICT:仅当 version 由 CLI 本轮自读时,重读后以新 requestId 重试;
9
+ * 显式传入的 version 绝不自动重试(不把调用方意图变成隐式覆盖)
10
+ */
11
+ import { randomUUID } from 'node:crypto'
12
+
13
+ import { CliError } from './errors.mjs'
14
+
15
+ export function newRequestId() {
16
+ return randomUUID()
17
+ }
18
+
19
+ export function isDefinitiveRejection(err) {
20
+ if (!(err instanceof CliError)) return false
21
+ if (err.httpStatus === null) return false
22
+ return err.httpStatus >= 400 && err.httpStatus < 500 && err.httpStatus !== 429
23
+ }
24
+
25
+ export function isVersionConflict(err) {
26
+ return err instanceof CliError && (err.reasonCode === 'VERSION_CONFLICT' || err.reasonCode === 'GRADE_VERSION_CONFLICT')
27
+ }
28
+
29
+ function currentVersionOf(err) {
30
+ const payload = err && err.payload
31
+ if (!payload || typeof payload !== 'object') return null
32
+ for (const key of ['currentVersion', 'version', 'expectedRevision', 'latestVersion']) {
33
+ if (typeof payload[key] === 'number') return payload[key]
34
+ }
35
+ return null
36
+ }
37
+
38
+ /**
39
+ * @param {object} options
40
+ * @param {number|undefined} options.explicitVersion 调用方显式传入的 version(存在则不自动重试)
41
+ * @param {string|null} [options.explicitRequestId] 调用方用 --request-id 指定的 id,只作用于首次请求
42
+ * @param {() => Promise<number>} options.readVersion 从服务端重读最新 version
43
+ * @param {({requestId:string|null, version:number}) => Promise<unknown>} options.run 实际写操作
44
+ * @param {number} [options.maxRetries] 最大自动重试次数(默认 2)
45
+ * @param {(message:string) => void} [options.log] stderr 进度日志
46
+ */
47
+ export async function runWithVersionRetry({ explicitVersion, explicitRequestId = null, readVersion, run, maxRetries = 2, log = () => {} }) {
48
+ if (explicitVersion !== undefined) {
49
+ return run({ requestId: explicitRequestId || newRequestId(), version: Number(explicitVersion) })
50
+ }
51
+ let attempt = 0
52
+ for (;;) {
53
+ const version = await readVersion()
54
+ // 重试必须换 requestId:版本冲突重试的 body 里 version 一定变了,而后端
55
+ // AiCodingOperations.replay() 对「同 requestId + 不同 bodyHash」直接抛
56
+ // IDEMPOTENCY_CONFLICT——沿用调用方给的 id 会让自动重试永远失败。
57
+ const requestId = attempt === 0 ? explicitRequestId || newRequestId() : newRequestId()
58
+ if (attempt > 0 && explicitRequestId) {
59
+ log(`本次重试用新的 requestId(--request-id ${explicitRequestId} 只作用于首次请求;同 id 配不同 body 会被判 IDEMPOTENCY_CONFLICT)`)
60
+ }
61
+ try {
62
+ return await run({ requestId, version })
63
+ } catch (err) {
64
+ if (isVersionConflict(err) && attempt < maxRetries) {
65
+ attempt += 1
66
+ const current = currentVersionOf(err)
67
+ log(
68
+ `版本冲突(服务端当前版本:${current ?? version}),自动重读重试 ${attempt}/${maxRetries}`,
69
+ )
70
+ continue
71
+ }
72
+ throw err
73
+ }
74
+ }
75
+ }
package/src/output.mjs ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * 输出层:stdout 只承载稳定的 JSON 信封(Agent 契约),人类可读渲染走 --pretty。
3
+ * stderr 只承载进度/提示,绝不混入 stdout。
4
+ */
5
+
6
+ /** 递归排序对象键,保证 JSON 输出稳定可 diff */
7
+ function sortKeys(value) {
8
+ if (Array.isArray(value)) return value.map(sortKeys)
9
+ if (value && typeof value === 'object') {
10
+ const out = {}
11
+ for (const key of Object.keys(value).sort()) out[key] = sortKeys(value[key])
12
+ return out
13
+ }
14
+ return value
15
+ }
16
+
17
+ export function stableStringify(value) {
18
+ return JSON.stringify(sortKeys(value))
19
+ }
20
+
21
+ const WIDE_CHAR = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/
22
+
23
+ function displayWidth(text) {
24
+ let width = 0
25
+ for (const ch of String(text)) width += WIDE_CHAR.test(ch) ? 2 : 1
26
+ return width
27
+ }
28
+
29
+ function truncate(text, maxWidth) {
30
+ const s = String(text)
31
+ if (displayWidth(s) <= maxWidth) return s
32
+ let out = ''
33
+ let width = 0
34
+ for (const ch of s) {
35
+ const w = WIDE_CHAR.test(ch) ? 2 : 1
36
+ if (width + w > maxWidth - 1) break
37
+ out += ch
38
+ width += w
39
+ }
40
+ return `${out}…`
41
+ }
42
+
43
+ function pad(text, width) {
44
+ const gap = width - displayWidth(text)
45
+ return gap > 0 ? text + ' '.repeat(gap) : text
46
+ }
47
+
48
+ function scalarText(value) {
49
+ if (value === null || value === undefined) return ''
50
+ if (typeof value === 'object') return truncate(stableStringify(value), 48)
51
+ return truncate(value, 48)
52
+ }
53
+
54
+ function renderTable(rows, out) {
55
+ if (rows.length === 0) {
56
+ out.push('(无数据)')
57
+ return
58
+ }
59
+ const columns = []
60
+ const seen = new Set()
61
+ for (const row of rows) {
62
+ if (row && typeof row === 'object') {
63
+ for (const key of Object.keys(row)) {
64
+ if (!seen.has(key)) {
65
+ seen.add(key)
66
+ columns.push(key)
67
+ }
68
+ }
69
+ }
70
+ }
71
+ if (columns.length === 0) {
72
+ for (const row of rows) out.push(scalarText(row))
73
+ return
74
+ }
75
+ const widths = columns.map((key) => Math.max(displayWidth(key), ...rows.map((row) => displayWidth(scalarText(row && typeof row === 'object' ? row[key] : row)))))
76
+ out.push(columns.map((key, i) => pad(key, widths[i])).join(' '))
77
+ out.push(widths.map((w) => '─'.repeat(w)).join(' '))
78
+ for (const row of rows) {
79
+ out.push(columns.map((key, i) => pad(scalarText(row && typeof row === 'object' ? row[key] : row), widths[i])).join(' '))
80
+ }
81
+ out.push(`(共 ${rows.length} 行)`)
82
+ }
83
+
84
+ function renderValue(value, indent, out) {
85
+ const padStr = ' '.repeat(indent)
86
+ if (Array.isArray(value)) {
87
+ if (value.length === 0) {
88
+ out.push(`${padStr}(无数据)`)
89
+ return
90
+ }
91
+ if (value.every((item) => item && typeof item === 'object')) {
92
+ renderTable(value, out)
93
+ return
94
+ }
95
+ for (const item of value) out.push(`${padStr}- ${scalarText(item)}`)
96
+ return
97
+ }
98
+ if (value && typeof value === 'object') {
99
+ const entries = Object.entries(value)
100
+ if (entries.length === 0) {
101
+ out.push(`${padStr}(空对象)`)
102
+ return
103
+ }
104
+ for (const [key, item] of entries) {
105
+ if (item && typeof item === 'object') {
106
+ out.push(`${padStr}${key}:`)
107
+ renderValue(item, indent + 2, out)
108
+ } else {
109
+ out.push(`${padStr}${pad(`${key}:`, Math.max(12, ...entries.map(([k]) => displayWidth(k)) + 1))} ${scalarText(item)}`)
110
+ }
111
+ }
112
+ return
113
+ }
114
+ out.push(`${padStr}${scalarText(value)}`)
115
+ }
116
+
117
+ /** --pretty 模式的人类可读渲染(表格/键值) */
118
+ export function renderPretty(value) {
119
+ const out = []
120
+ renderValue(value, 0, out)
121
+ return out.join('\n')
122
+ }
123
+
124
+ /** 成功:stdout 输出信封 JSON 或 pretty 渲染(信封键序固定:ok → data;仅 data 负载递归排序保证稳定) */
125
+ export function printSuccess(stdout, data, { pretty = false } = {}) {
126
+ if (pretty) {
127
+ stdout.write(`${renderPretty(data)}\n`)
128
+ } else {
129
+ stdout.write(`${JSON.stringify({ ok: true, data: sortKeys(data) })}\n`)
130
+ }
131
+ }
132
+
133
+ /** 失败:stdout 输出错误信封(保持 Agent 可解析),stderr 输出人读提示 */
134
+ export function printFailure(stdout, stderr, error) {
135
+ const envelope = { ok: false, error: error.toJSON ? error.toJSON() : { code: 'UNKNOWN', message: String(error && error.message ? error.message : error) } }
136
+ stdout.write(`${JSON.stringify(envelope)}\n`)
137
+ if (error && error.hint) stderr.write(`提示:${error.hint}\n`)
138
+ }
package/src/path.mjs ADDED
@@ -0,0 +1,40 @@
1
+ import { usageError } from './errors.mjs'
2
+
3
+ /**
4
+ * 路径参数的本地校验与编码。
5
+ *
6
+ * 背景:位置参数此前直接插值进 path 模板(55 处),只有 ai.mjs 的 modelId 与
7
+ * uploads 的 attachmentId 做了 encodeURIComponent。实测 `ch candidates get "42?foo=bar"`
8
+ * 发出的是 `/users/42?foo=bar/info`——new URL() 把尾随的 /info 当成了查询串的值,
9
+ * 请求形状被静默改掉且仍然带 JWT 打向真实端点。
10
+ *
11
+ * 后端所有传统端点的 @PathVariable 都是 Long(UserController / AdminController /
12
+ * InterviewController 等),AI Coding 的 plan/attempt/candidate id 也是 long,
13
+ * 因此数字校验放在本地既拦住了畸形输入,也避免了 Spring 类型转换那套更难读的报错。
14
+ */
15
+
16
+ /** 数字型路径参数:后端 @PathVariable Long / long */
17
+ export function idParam(raw, { label = 'id' } = {}) {
18
+ const text = String(raw ?? '').trim()
19
+ if (text === '') {
20
+ throw usageError(`缺少 ${label}`, `用法见 ch help,${label} 是正整数`)
21
+ }
22
+ if (!/^\d+$/.test(text)) {
23
+ throw usageError(`${label} 必须是正整数(收到:${text})`, '路径参数只能是数字;若取值里带 ? # / 空格,说明拼错了或需要编码')
24
+ }
25
+ return text
26
+ }
27
+
28
+ /**
29
+ * 字符串型路径参数:允许文本,但必须逐段编码。
30
+ * allowEmpty 给「同一命令里 id 是否必填取决于 action」的多动作命令用
31
+ * (ch ai plans list 不带 id,ch ai plans get <id> 才带),缺失时交回调用方的显式检查。
32
+ */
33
+ export function textParam(raw, { label = 'id', allowEmpty = false } = {}) {
34
+ const text = String(raw ?? '')
35
+ if (text.trim() === '') {
36
+ if (allowEmpty && raw === undefined) return undefined
37
+ throw usageError(`缺少 ${label}`)
38
+ }
39
+ return encodeURIComponent(text)
40
+ }
@@ -0,0 +1,137 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ import { usageError } from './errors.mjs'
4
+
5
+ /**
6
+ * 写命令载荷构造。
7
+ * 语义化 flag 提供基础字段;`-d/--data`(JSON 或 @file)整体覆盖/扩展,
8
+ * 保证 Agent 对任意载荷字段有完全控制权(后端新增字段时 CLI 不落后)。
9
+ */
10
+
11
+ /**
12
+ * 取 `-d/--data` 的文本:`@path` 视为文件,其余视为字面量。
13
+ * 文件读不到(ENOENT/EISDIR/权限)属于调用方写错了路径,必须是 exit 2,
14
+ * 不能让它冒泡成 exit 1 的业务错误——Agent 会据此以为后端出了问题而重试。
15
+ */
16
+ export function readDataText(raw, { label = '-d/--data' } = {}) {
17
+ if (typeof raw !== 'string' || raw === '') {
18
+ throw usageError(`${label} 需要一个 JSON 参数`, `可用 ${label} '{"k":1}' 或 ${label} @payload.json`)
19
+ }
20
+ if (!raw.startsWith('@')) return raw
21
+ const file = raw.slice(1)
22
+ try {
23
+ return readFileSync(file, 'utf8')
24
+ } catch (err) {
25
+ throw usageError(`${label} 指定的文件无法读取:${file}(${err.code || err.message})`, '检查路径是否存在、是否为可读的文本文件')
26
+ }
27
+ }
28
+
29
+ /** 解析 `-d/--data` 为任意 JSON 值(形状校验留给调用方) */
30
+ export function parseDataJson(raw, { label = '-d/--data' } = {}) {
31
+ const text = readDataText(raw, { label })
32
+ try {
33
+ return JSON.parse(text)
34
+ } catch (err) {
35
+ throw usageError(`${label} 不是合法 JSON:${err.message}`, `可用 ${label} @payload.json 从文件读取`)
36
+ }
37
+ }
38
+
39
+ export function payloadFrom(ctx, base = {}) {
40
+ const raw = ctx.flags.get('data') ?? ctx.flags.get('d')
41
+ if (raw === undefined || raw === '') return base
42
+ const parsed = parseDataJson(raw)
43
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
44
+ throw usageError('-d/--data 必须是 JSON 对象')
45
+ }
46
+ return { ...base, ...parsed }
47
+ }
48
+
49
+ /** kebab-case flag → 驼峰字段名:flag 名与后端字段同名异形式,映射只写一处 */
50
+ export function camelCase(flag) {
51
+ return flag.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
52
+ }
53
+
54
+ /**
55
+ * 逗号分隔的 id 列表 → 正整数数组。
56
+ * `split(',').map(Number)` 会把 'abc' 变成 NaN、把空段变成 0,而 JSON.stringify(NaN)
57
+ * 是 null:`--authorized-group-ids 1,abc,3` 实际发给后端的是 [1,null,3],
58
+ * `"2, ,3"` 则是 [2,0,3]——授权到一个不存在的组别,还一路 exit 0。
59
+ */
60
+ export function parseIdList(raw, { flag = 'group-ids', max = 200 } = {}) {
61
+ const parts = String(raw).split(',')
62
+ if (parts.length > max) {
63
+ throw usageError(`--${flag} 最多 ${max} 个(当前 ${parts.length} 个)`)
64
+ }
65
+ return parts.map((part) => {
66
+ const text = part.trim()
67
+ if (!/^\d+$/.test(text) || Number(text) < 1) {
68
+ throw usageError(
69
+ `--${flag} 含无效 id:${text === '' ? '(空段)' : text}`,
70
+ `用逗号分隔的正整数,如 --${flag} 1,2,3;不要留空段或尾随逗号`,
71
+ )
72
+ }
73
+ return Number(text)
74
+ })
75
+ }
76
+
77
+ /** 读取字符串 flag;required 时缺失抛用法错误 */
78
+ export function flagString(ctx, name, { required = false, label } = {}) {
79
+ const value = ctx.flags.get(name)
80
+ if (typeof value !== 'string' || value === '') {
81
+ if (required) {
82
+ throw usageError(`缺少 --${name}`, label ? `用法:${label}` : undefined)
83
+ }
84
+ return undefined
85
+ }
86
+ return value
87
+ }
88
+
89
+ /** 读取数字 flag;required 时缺失抛用法错误,非数字抛用法错误 */
90
+ export function flagNumber(ctx, name, { required = false, label } = {}) {
91
+ const value = ctx.flags.get(name)
92
+ if (value === undefined || value === true || value === '') {
93
+ if (required) throw usageError(`缺少 --${name}`, label ? `用法:${label}` : undefined)
94
+ return undefined
95
+ }
96
+ const num = Number(value)
97
+ if (!Number.isFinite(num)) throw usageError(`--${name} 不是有效数字:${value}`)
98
+ return num
99
+ }
100
+
101
+ /** 读取枚举 flag;不在候列举内抛用法错误 */
102
+ export function flagEnum(ctx, name, choices, { required = false, label } = {}) {
103
+ const value = flagString(ctx, name, { required, label })
104
+ if (value === undefined) return undefined
105
+ if (!choices.includes(value)) {
106
+ throw usageError(`--${name} 必须是 ${choices.join(' / ')}之一(收到:${value})`)
107
+ }
108
+ return value
109
+ }
110
+
111
+ /**
112
+ * 布尔取值解析:true|false|1|0|yes|no(大小写不敏感),其余一律用法错误。
113
+ * 之前有三套各自为政的解析(flagBool、candidates 内联、query.parseBool),
114
+ * 语义都不同且都把不认识的写法当成 false:`ch candidates star 1 --starred yes`
115
+ * 会提交 starred:false(与意图完全相反)并 exit 0。宁可直接报错。
116
+ */
117
+ export function parseBoolFlag(value, { flag } = {}) {
118
+ const text = String(value).trim().toLowerCase()
119
+ if (text === 'true' || text === '1' || text === 'yes' || text === 'y' || text === 'on') return true
120
+ if (text === 'false' || text === '0' || text === 'no' || text === 'n' || text === 'off') return false
121
+ const label = flag ? `--${flag}` : '布尔参数'
122
+ throw usageError(`${label} 需要布尔取值(收到:${value})`, '可用 true / false / 1 / 0 / yes / no')
123
+ }
124
+
125
+ /** 读取布尔 flag(--flag true|false,裸写视为 true,--no-flag 视为 false) */
126
+ export function flagBool(ctx, name) {
127
+ const value = ctx.flags.get(name)
128
+ if (value === undefined) return undefined
129
+ if (value === true) return true
130
+ if (value === false) return false
131
+ return parseBoolFlag(value, { flag: name })
132
+ }
133
+
134
+ /** 布尔 flag 的存在性判定:`--show-token` / `--show-token=false` 都要给出正确答案 */
135
+ export function flagOn(ctx, name) {
136
+ return flagBool(ctx, name) === true
137
+ }
package/src/prompt.mjs ADDED
@@ -0,0 +1,47 @@
1
+ import readline from 'node:readline'
2
+ import { Writable } from 'node:stream'
3
+
4
+ /** TTY 普通输入;提示走 stderr,保留 stdout 的机器可读信封。 */
5
+ export function promptText(prompt) {
6
+ return new Promise((resolve, reject) => {
7
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true })
8
+ rl.question(prompt, answer => { rl.close(); resolve(answer) })
9
+ rl.on('SIGINT', () => { rl.close(); reject(new Error('已取消输入')) })
10
+ })
11
+ }
12
+
13
+ /** 从 JWT payload 解码 exp(秒)→ epoch 毫秒;失败返回 null */
14
+ export function decodeTokenExpiry(token) {
15
+ try {
16
+ const parts = String(token).split('.')
17
+ if (parts.length < 2) return null
18
+ const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/')
19
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
20
+ const payload = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'))
21
+ return typeof payload.exp === 'number' && Number.isFinite(payload.exp) ? payload.exp * 1000 : null
22
+ } catch {
23
+ return null
24
+ }
25
+ }
26
+
27
+ export function sessionExpiryState(expiresAt, now = Date.now()) {
28
+ if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) return 'unknown'
29
+ const remaining = expiresAt - now
30
+ if (remaining <= 0) return 'expired'
31
+ if (remaining <= 15 * 60 * 1000) return 'expiring'
32
+ return 'ok'
33
+ }
34
+
35
+ /** 交互式密码输入:readline 的输出被静音,不依赖平台的 stty。 */
36
+ export function promptPassword(prompt = '密码:', { input = process.stdin, output = process.stderr } = {}) {
37
+ return new Promise((resolve, reject) => {
38
+ const silent = new Writable({ write(_chunk, _encoding, callback) { callback() } })
39
+ silent.isTTY = true
40
+ silent.columns = output.columns || 80
41
+ const rl = readline.createInterface({ input, output: silent, terminal: true })
42
+ output.write(prompt)
43
+ const finish = () => { rl.close(); silent.end(); output.write('\n') }
44
+ rl.question('', answer => { finish(); resolve(answer) })
45
+ rl.on('SIGINT', () => { finish(); reject(new Error('已取消输入')) })
46
+ })
47
+ }
package/src/query.mjs ADDED
@@ -0,0 +1,44 @@
1
+ /** 查询参数构造:flag → query key 的显式映射(与 api.ts 的参数名逐一对应) */
2
+ import { CliError, usageError } from './errors.mjs'
3
+
4
+ /**
5
+ * @param {object} ctx CLI 上下文(flags)
6
+ * @param {Record<string, {key?:string, parse?:(v:string)=>unknown}>} spec
7
+ * @returns {Array<[string, unknown]>}
8
+ */
9
+ export function collectQuery(ctx, spec) {
10
+ const query = []
11
+ for (const [flagName, options] of Object.entries(spec)) {
12
+ const value = ctx.flags.get(flagName)
13
+ if (value === undefined || value === true || value === false || value === '') continue
14
+ const parse = options.parse || String
15
+ let parsed
16
+ try {
17
+ parsed = parse(value)
18
+ } catch (err) {
19
+ // 解析器可能抛普通 Error(如内联转换);本地校验失败一律归为用法错误(exit 2)
20
+ if (err instanceof CliError) throw err
21
+ throw usageError(`--${flagName} 取值无效:${err && err.message ? err.message : err}`)
22
+ }
23
+ query.push([options.key || flagName, parsed])
24
+ }
25
+ return query
26
+ }
27
+
28
+ export function parseNumber(value) {
29
+ const num = Number(value)
30
+ if (!Number.isFinite(num)) {
31
+ throw usageError(`不是有效数字:${value}`, '数字型参数只接受纯数字,如 --page 2')
32
+ }
33
+ return num
34
+ }
35
+
36
+ /** 从 `-d/--data` 读取 JSON 载荷(支持 @file);不存在返回 null */
37
+ export function readDataPayload(ctx) {
38
+ const raw = ctx.flags.get('data') ?? ctx.flags.get('d')
39
+ if (raw === undefined) return null
40
+ if (raw === true) {
41
+ throw usageError('-d/--data 需要一个 JSON 参数', '可用 -d \'{"k":1}\' 或 -d @payload.json')
42
+ }
43
+ return raw
44
+ }
@@ -0,0 +1,50 @@
1
+ import { usageError } from './errors.mjs'
2
+
3
+ /**
4
+ * 敏感写操作的 reason 前置校验。
5
+ * 这些操作后端强制要求审计原因(三面改组、解绑微信、驳回申请、作废考次、
6
+ * 已公布评分修正、撤回计划、安排重考、轮换模型密钥)。CLI 在本地先行拦截,
7
+ * 缺 --reason 直接 exit 2,不消耗服务端配额、也不产生 4xx 噪音。
8
+ */
9
+ export function requireReason(ctx, { max = 200, label } = {}) {
10
+ const value = ctx.flags.get('reason')
11
+ if (typeof value !== 'string' || value.trim() === '') {
12
+ throw usageError(
13
+ '该操作必须提供 --reason(审计原因)',
14
+ label ? `用法:${label}` : undefined,
15
+ )
16
+ }
17
+ const reason = value.trim()
18
+ if (reason.length > max) {
19
+ throw usageError(`--reason 不能超过 ${max} 字(当前 ${reason.length} 字)`)
20
+ }
21
+ return reason
22
+ }
23
+
24
+ /**
25
+ * 可选的审计原因:允许不提供(返回 undefined,调用方不写这个键,由后端补默认文案),
26
+ * 但一旦提供就必须与 requireReason 同一套约束(非空、≤max 字)。
27
+ * `ch interviews attendance` 的 reason 在后端确实是可选的
28
+ * (AdminInterviewAttendanceController:74 把非字符串归一化成 null),
29
+ * 所以不能改成 requireReason——那会把合法操作变成拒绝。
30
+ * 但长度上限必须一致,否则一条 --reason x10000 会原样进审计日志。
31
+ */
32
+ export function optionalReason(ctx, { max = 200, label } = {}) {
33
+ const value = ctx.flags.get('reason')
34
+ if (typeof value !== 'string' || value.trim() === '') return undefined
35
+ const reason = value.trim()
36
+ if (reason.length > max) {
37
+ throw usageError(
38
+ `--reason 不能超过 ${max} 字(当前 ${reason.length} 字)`,
39
+ label ? `用法:${label}` : undefined,
40
+ )
41
+ }
42
+ return reason
43
+ }
44
+
45
+ /** 声明某命令需要 reason(供 help 与契约守护测试使用) */
46
+ export const REQUIRES_REASON_COMMANDS = new Set([
47
+ 'candidates unbind-wechat',
48
+ 'candidates third-round-choice',
49
+ 'applies reject',
50
+ ])