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.
- package/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/SECURITY.md +11 -0
- package/THIRD_PARTY_NOTICES.md +7 -0
- package/bin/ch.mjs +11 -0
- package/docs/00-overview.md +71 -0
- package/docs/01-command-reference.md +1206 -0
- package/docs/02-agent-contract.md +118 -0
- package/docs/03-agent-setup.md +69 -0
- package/docs/04-audit.md +3 -0
- package/docs/05-release.md +5 -0
- package/docs/06-coverage.md +3 -0
- package/docs/07-data-analysis.md +105 -0
- package/docs/08-installation.md +36 -0
- package/docs/security-review.md +26 -0
- package/integrations/codex/.agents/plugins/marketplace.json +20 -0
- package/integrations/codex/.codex-plugin/plugin.json +10 -0
- package/integrations/codex/LICENSE +21 -0
- package/integrations/codex/README.md +5 -0
- package/integrations/codex/package.json +7 -0
- package/integrations/codex/plugin.json +11 -0
- package/integrations/codex/skills/cloudhouse-admin/SKILL.md +32 -0
- package/integrations/codex/skills/cloudhouse-admin/references/01-command-reference.md +1206 -0
- package/integrations/codex/skills/cloudhouse-admin/references/02-agent-contract.md +118 -0
- package/integrations/dsh/LICENSE +21 -0
- package/integrations/dsh/README.md +14 -0
- package/integrations/dsh/index.mjs +7 -0
- package/integrations/dsh/package.json +12 -0
- package/integrations/dsh/skills/cloudhouse-admin/SKILL.md +32 -0
- package/integrations/dsh/skills/cloudhouse-admin/references/01-command-reference.md +1206 -0
- package/integrations/dsh/skills/cloudhouse-admin/references/02-agent-contract.md +118 -0
- package/integrations/openclaw/LICENSE +21 -0
- package/integrations/openclaw/README.md +5 -0
- package/integrations/openclaw/package.json +7 -0
- package/integrations/openclaw/skills/cloudhouse-admin/SKILL.md +32 -0
- package/integrations/openclaw/skills/cloudhouse-admin/references/01-command-reference.md +1206 -0
- package/integrations/openclaw/skills/cloudhouse-admin/references/02-agent-contract.md +118 -0
- package/integrations/opencode/LICENSE +21 -0
- package/integrations/opencode/README.md +5 -0
- package/integrations/opencode/package.json +7 -0
- package/integrations/opencode/skills/cloudhouse-admin/SKILL.md +32 -0
- package/integrations/opencode/skills/cloudhouse-admin/references/01-command-reference.md +1206 -0
- package/integrations/opencode/skills/cloudhouse-admin/references/02-agent-contract.md +118 -0
- package/integrations/pi/LICENSE +21 -0
- package/integrations/pi/README.md +5 -0
- package/integrations/pi/package.json +15 -0
- package/integrations/pi/skills/cloudhouse-admin/SKILL.md +32 -0
- package/integrations/pi/skills/cloudhouse-admin/references/01-command-reference.md +1206 -0
- package/integrations/pi/skills/cloudhouse-admin/references/02-agent-contract.md +118 -0
- package/package.json +44 -0
- package/skills/cloudhouse-admin/SKILL.md +32 -0
- package/skills/cloudhouse-admin/references/01-command-reference.md +1206 -0
- package/skills/cloudhouse-admin/references/02-agent-contract.md +118 -0
- package/src/agent-install.mjs +50 -0
- package/src/analysis-resources.json +1207 -0
- package/src/argv.mjs +88 -0
- package/src/capabilities.mjs +18 -0
- package/src/cli.mjs +180 -0
- package/src/client.mjs +169 -0
- package/src/commands/accounts.mjs +115 -0
- package/src/commands/agent.mjs +13 -0
- package/src/commands/ai.mjs +420 -0
- package/src/commands/analysis.mjs +124 -0
- package/src/commands/api.mjs +63 -0
- package/src/commands/applies.mjs +89 -0
- package/src/commands/assistant.mjs +53 -0
- package/src/commands/auth.mjs +109 -0
- package/src/commands/candidates.mjs +252 -0
- package/src/commands/checkin.mjs +41 -0
- package/src/commands/dashboard.mjs +11 -0
- package/src/commands/evaluations.mjs +27 -0
- package/src/commands/extended.mjs +96 -0
- package/src/commands/feishu.mjs +31 -0
- package/src/commands/groups.mjs +66 -0
- package/src/commands/index.mjs +101 -0
- package/src/commands/interviews.mjs +151 -0
- package/src/commands/load.mjs +27 -0
- package/src/commands/notifications.mjs +131 -0
- package/src/commands/plans.mjs +90 -0
- package/src/commands/profile.mjs +43 -0
- package/src/commands/slots.mjs +95 -0
- package/src/commands/uploads.mjs +169 -0
- package/src/config.mjs +68 -0
- package/src/errors.mjs +156 -0
- package/src/flags.mjs +114 -0
- package/src/idempotent.mjs +75 -0
- package/src/output.mjs +138 -0
- package/src/path.mjs +40 -0
- package/src/payload.mjs +137 -0
- package/src/prompt.mjs +47 -0
- package/src/query.mjs +44 -0
- package/src/sensitive.mjs +50 -0
- package/src/session.mjs +94 -0
- package/src/time.mjs +87 -0
- package/src/version.mjs +1 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { register } from './index.mjs'
|
|
4
|
+
import { flagString, flagOn, flagNumber, payloadFrom } from '../payload.mjs'
|
|
5
|
+
import { idParam, textParam } from '../path.mjs'
|
|
6
|
+
import { usageError } from '../errors.mjs'
|
|
7
|
+
import { collectQuery, parseNumber } from '../query.mjs'
|
|
8
|
+
import { runWithVersionRetry } from '../idempotent.mjs'
|
|
9
|
+
|
|
10
|
+
register('second-round-choices page', {
|
|
11
|
+
summary: '分页查询二轮组别选择',
|
|
12
|
+
usage: 'ch second-round-choices page [--page N] [--size N]',
|
|
13
|
+
endpoints: ['GET /admin/second-round-choices/page'],
|
|
14
|
+
run: async ctx => (await ctx.client.request({ path: '/admin/second-round-choices/page', query: collectQuery(ctx, { page: { parse: parseNumber }, size: { parse: parseNumber } }) })).data,
|
|
15
|
+
})
|
|
16
|
+
register('slots attendance', {
|
|
17
|
+
summary: '查看场次到场名单', usage: 'ch slots attendance <slotId>',
|
|
18
|
+
endpoints: ['GET /admin/interview-slots/{slotId}/attendance'],
|
|
19
|
+
run: async ctx => (await ctx.client.request({ path: `/admin/interview-slots/${idParam(ctx.positionals[0])}/attendance` })).data,
|
|
20
|
+
})
|
|
21
|
+
for (const [name, summary, endpoint] of [
|
|
22
|
+
['feishu backfill-absence', '补推当天缺勤通知(会向真人发送通知,仅全局管理员)', '/admin/feishu/notifications/backfill-today-absence'],
|
|
23
|
+
['feishu refresh-document', '刷新飞书场次文档(外部写入,仅全局管理员)', '/admin/feishu/upcoming-slots-document/refresh'],
|
|
24
|
+
]) register(name, { summary, usage: `ch ${name}`, permission: 'global-admin', endpoints: [`POST ${endpoint}`], run: async ctx => (await ctx.client.request({ method: 'POST', path: endpoint })).data })
|
|
25
|
+
register('feishu deliveries', {
|
|
26
|
+
summary: '查询飞书投递队列(最多200条,仅全局管理员)', usage: 'ch feishu deliveries [--status QUEUED|SENDING|SENT|FAILED|SKIPPED|RETRY]', permission: 'global-admin',
|
|
27
|
+
endpoints: ['GET /admin/feishu/deliveries'],
|
|
28
|
+
run: async ctx => (await ctx.client.request({ path: '/admin/feishu/deliveries', query: collectQuery(ctx, { status: {} }) })).data,
|
|
29
|
+
})
|
|
30
|
+
register('feishu requeue', {
|
|
31
|
+
summary: '重放失败/跳过投递(可能向真人补发通知,仅全局管理员)', usage: 'ch feishu requeue [--ids 1,2] [--all]', permission: 'global-admin',
|
|
32
|
+
endpoints: ['POST /admin/feishu/deliveries/requeue'],
|
|
33
|
+
run: async ctx => {
|
|
34
|
+
const raw = flagString(ctx, 'ids')
|
|
35
|
+
const all = flagOn(ctx, 'all')
|
|
36
|
+
if ((raw === undefined) === !all) throw usageError('必须且只能指定 --ids 1,2 或 --all,避免误重放全部队列')
|
|
37
|
+
const ids = raw === undefined ? [] : raw.split(',').map(s => {
|
|
38
|
+
if (!/^[1-9]\d*$/.test(s.trim()) || !Number.isSafeInteger(Number(s))) throw usageError('--ids 必须是逗号分隔的正整数')
|
|
39
|
+
return Number(s)
|
|
40
|
+
})
|
|
41
|
+
return (await ctx.client.request({ method: 'POST', path: '/admin/feishu/deliveries/requeue', body: { ids } })).data
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
const pending = 'material-authoring backend (not included in this CLI release)'
|
|
45
|
+
register('ai materials', {
|
|
46
|
+
summary: '上传/预览出题资料(需新版后端;上传不支持requestId去重)', backendRequirement: pending,
|
|
47
|
+
usage: 'ch ai materials upload <file>\nch ai materials get <materialId>',
|
|
48
|
+
endpoints: ['POST /admin/ai-coding/materials', 'GET /admin/ai-coding/materials/{id}'],
|
|
49
|
+
run: async ctx => {
|
|
50
|
+
const [action, value] = ctx.positionals
|
|
51
|
+
if (action === 'get') {
|
|
52
|
+
if (!/^[a-f0-9]{32}\.(md|txt)$/.test(String(value))) throw usageError('materialId 必须是服务器返回的资料 id')
|
|
53
|
+
return (await ctx.client.request({ path: `/admin/ai-coding/materials/${value}` })).data
|
|
54
|
+
}
|
|
55
|
+
if (action !== 'upload' || !value) throw usageError('用法:ch ai materials upload <file> 或 get <materialId>')
|
|
56
|
+
const ext = path.extname(value).toLowerCase()
|
|
57
|
+
if (!['.md', '.txt'].includes(ext)) throw usageError('资料仅支持 UTF-8 .md/.txt')
|
|
58
|
+
let bytes
|
|
59
|
+
try {
|
|
60
|
+
const stat = fs.statSync(value)
|
|
61
|
+
if (!stat.isFile() || stat.size === 0 || stat.size > 262144) throw usageError('资料须为非空文件,≤256 KiB')
|
|
62
|
+
bytes = fs.readFileSync(value)
|
|
63
|
+
} catch (err) { if (err.exitCode) throw err; throw usageError(`资料无法读取:${value}`) }
|
|
64
|
+
let text
|
|
65
|
+
try { text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) } catch { throw usageError('资料必须为 UTF-8 编码') }
|
|
66
|
+
if (!text.trim() || text.includes('\0')) throw usageError('资料须为非空 UTF-8 文本且不能含 NUL')
|
|
67
|
+
const formData = new FormData()
|
|
68
|
+
formData.append('file', new Blob([bytes], { type: 'text/plain' }), path.basename(value))
|
|
69
|
+
return (await ctx.client.request({ method: 'POST', path: '/admin/ai-coding/materials', formData })).data
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
register('ai papers', {
|
|
73
|
+
summary: '创建出题任务/查询任务/确认题目(需新版后端)', backendRequirement: pending, requestId: true,
|
|
74
|
+
usage: 'ch ai papers generate <planId> <paperId> [--version N] [--request-id ID]\nch ai papers get-generation <planId> <paperId> <jobId>\nch ai papers confirm <planId> <paperId> [--version N] [--request-id ID]',
|
|
75
|
+
endpoints: ['POST /admin/ai-coding/plans/{planId}/papers/{paperId}/generations', 'GET /admin/ai-coding/plans/{planId}/papers/{paperId}/generations/{jobId}', 'POST /admin/ai-coding/plans/{planId}/papers/{paperId}/confirm'],
|
|
76
|
+
run: async ctx => {
|
|
77
|
+
const [action, planRaw, paperRaw, jobRaw] = ctx.positionals
|
|
78
|
+
if (!['generate', 'get-generation', 'confirm'].includes(action)) throw usageError('ai papers 子命令必须是 generate|get-generation|confirm')
|
|
79
|
+
const planId = idParam(planRaw, { label: 'planId' })
|
|
80
|
+
const paperId = textParam(paperRaw, { label: 'paperId' })
|
|
81
|
+
const base = `/admin/ai-coding/plans/${planId}/papers/${paperId}`
|
|
82
|
+
if (action === 'get-generation') return (await ctx.client.request({ path: `${base}/generations/${idParam(jobRaw, { label: 'jobId' })}` })).data
|
|
83
|
+
const original = payloadFrom(ctx, {})
|
|
84
|
+
const explicitVersion = flagNumber(ctx, 'version') ?? original.version
|
|
85
|
+
if (explicitVersion !== undefined && (!Number.isSafeInteger(Number(explicitVersion)) || Number(explicitVersion) < 0)) throw usageError('version 必须是非负整数')
|
|
86
|
+
return runWithVersionRetry({
|
|
87
|
+
explicitVersion, explicitRequestId: flagString(ctx, 'request-id') || original.requestId, log: ctx.log,
|
|
88
|
+
readVersion: async () => {
|
|
89
|
+
const { data } = await ctx.client.request({ path: `/admin/ai-coding/plans/${planId}` })
|
|
90
|
+
if (!Number.isSafeInteger(data?.version)) throw usageError('计划详情中缺少 version')
|
|
91
|
+
return data.version
|
|
92
|
+
},
|
|
93
|
+
run: async ({ version, requestId }) => (await ctx.client.request({ method: 'POST', path: `${base}/${action === 'generate' ? 'generations' : 'confirm'}`, body: { ...original, version, requestId } })).data,
|
|
94
|
+
})
|
|
95
|
+
},
|
|
96
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { register } from './index.mjs'
|
|
2
|
+
|
|
3
|
+
register('feishu status', {
|
|
4
|
+
summary: '飞书签到集成状态',
|
|
5
|
+
usage: 'ch feishu status [--pretty]',
|
|
6
|
+
endpoints: ['GET /admin/feishu/status'],
|
|
7
|
+
run: async (ctx) => {
|
|
8
|
+
const { data } = await ctx.client.request({ method: 'GET', path: '/admin/feishu/status' })
|
|
9
|
+
return data
|
|
10
|
+
},
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
register('feishu reconcile', {
|
|
14
|
+
summary: '触发飞书场次双向对账',
|
|
15
|
+
usage: 'ch feishu reconcile',
|
|
16
|
+
endpoints: ['POST /admin/feishu/reconcile'],
|
|
17
|
+
run: async (ctx) => {
|
|
18
|
+
const { data } = await ctx.client.request({ method: 'POST', path: '/admin/feishu/reconcile' })
|
|
19
|
+
return data === null ? { reconciled: true } : data
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
register('feishu conflicts', {
|
|
24
|
+
summary: '飞书同步冲突列表',
|
|
25
|
+
usage: 'ch feishu conflicts [--pretty]',
|
|
26
|
+
endpoints: ['GET /admin/feishu/conflicts'],
|
|
27
|
+
run: async (ctx) => {
|
|
28
|
+
const { data } = await ctx.client.request({ method: 'GET', path: '/admin/feishu/conflicts' })
|
|
29
|
+
return data
|
|
30
|
+
},
|
|
31
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { register } from './index.mjs'
|
|
2
|
+
import { usageError } from '../errors.mjs'
|
|
3
|
+
import { flagString, payloadFrom } from '../payload.mjs'
|
|
4
|
+
import { idParam } from '../path.mjs'
|
|
5
|
+
|
|
6
|
+
const MAX_REQUIREMENT_LENGTH = 65
|
|
7
|
+
|
|
8
|
+
function groupPayload(ctx, { requireName }) {
|
|
9
|
+
const base = {}
|
|
10
|
+
const groupName = flagString(ctx, 'group-name', { required: requireName, label: 'ch groups create --group-name 名称' })
|
|
11
|
+
if (groupName !== undefined) base.groupName = groupName
|
|
12
|
+
const introduction = flagString(ctx, 'introduction')
|
|
13
|
+
if (introduction !== undefined) base.introduction = introduction
|
|
14
|
+
const requirement = flagString(ctx, 'requirement')
|
|
15
|
+
if (requirement !== undefined) {
|
|
16
|
+
if (requirement.length > MAX_REQUIREMENT_LENGTH) {
|
|
17
|
+
throw usageError(`--requirement 不能超过 ${MAX_REQUIREMENT_LENGTH} 字(当前 ${requirement.length} 字)`)
|
|
18
|
+
}
|
|
19
|
+
base.requirement = requirement
|
|
20
|
+
}
|
|
21
|
+
return payloadFrom(ctx, base)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
register('groups list', {
|
|
25
|
+
summary: '组别列表',
|
|
26
|
+
usage: 'ch groups list [--pretty]',
|
|
27
|
+
endpoints: ['GET /groups'],
|
|
28
|
+
run: async (ctx) => {
|
|
29
|
+
const { data } = await ctx.client.request({ method: 'GET', path: '/groups' })
|
|
30
|
+
return data
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
register('groups create', {
|
|
35
|
+
summary: '创建组别(SUPER/SYSTEM)',
|
|
36
|
+
usage: 'ch groups create --group-name 名称 [--introduction 简介] [--requirement 要求≤65字]',
|
|
37
|
+
endpoints: ['POST /groups'],
|
|
38
|
+
run: async (ctx) => {
|
|
39
|
+
const payload = groupPayload(ctx, { requireName: true })
|
|
40
|
+
const { data } = await ctx.client.request({ method: 'POST', path: '/groups', body: payload })
|
|
41
|
+
return data === null ? { created: true } : data
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
register('groups update', {
|
|
46
|
+
summary: '更新组别',
|
|
47
|
+
usage: 'ch groups update <id> --group-name 名称 [--introduction 简介] [--requirement 要求≤65字]',
|
|
48
|
+
endpoints: ['PUT /groups/{id}'],
|
|
49
|
+
run: async (ctx) => {
|
|
50
|
+
const id = idParam(ctx.positionals[0], { label: '组别 id' })
|
|
51
|
+
const payload = groupPayload(ctx, { requireName: true })
|
|
52
|
+
const { data } = await ctx.client.request({ method: 'PUT', path: `/groups/${id}`, body: payload })
|
|
53
|
+
return data === null ? { updated: true, id: Number(id) } : data
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
register('groups delete', {
|
|
58
|
+
summary: '删除组别(SUPER/SYSTEM)',
|
|
59
|
+
usage: 'ch groups delete <id>',
|
|
60
|
+
endpoints: ['DELETE /groups/{id}'],
|
|
61
|
+
run: async (ctx) => {
|
|
62
|
+
const id = idParam(ctx.positionals[0], { label: '组别 id' })
|
|
63
|
+
const { data } = await ctx.client.request({ method: 'DELETE', path: `/groups/${id}` })
|
|
64
|
+
return data === null ? { deleted: true, id: Number(id) } : data
|
|
65
|
+
},
|
|
66
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 命令注册表。每个命令声明其覆盖的后端端点(契约守护测试的数据源):
|
|
3
|
+
* api.ts 与后端 Controller 中的每个端点,必须被某条命令的 endpoints 声明,
|
|
4
|
+
* 或由通用兜底 `ch api` 覆盖(fallback: true 的命令不逐条声明)。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const commands = new Map()
|
|
8
|
+
|
|
9
|
+
export function register(name, spec) {
|
|
10
|
+
commands.set(name, Object.freeze({ name, ...spec }))
|
|
11
|
+
return commands.get(name)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getRegistry() {
|
|
15
|
+
return commands
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 最长前缀匹配:`ch ai plans list` → 注册键 'ai plans' + 位置参数 ['list'] */
|
|
19
|
+
export function resolveCommand(positionals) {
|
|
20
|
+
const maxLen = Math.min(positionals.length, 3)
|
|
21
|
+
for (let n = maxLen; n >= 1; n -= 1) {
|
|
22
|
+
const key = positionals.slice(0, n).join(' ')
|
|
23
|
+
const spec = commands.get(key)
|
|
24
|
+
if (spec) return { key, spec, args: positionals.slice(n) }
|
|
25
|
+
}
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 渲染帮助文本。
|
|
31
|
+
* @returns {{text:string, missing:boolean}} missing 时由入口转成 exit 2——
|
|
32
|
+
* 查不到子命令却 exit 0 会让 Agent 以为拿到了用法说明。
|
|
33
|
+
*/
|
|
34
|
+
export function renderHelp(subPath) {
|
|
35
|
+
if (subPath && subPath.length > 0) {
|
|
36
|
+
const key = subPath.join(' ')
|
|
37
|
+
const spec = commands.get(key)
|
|
38
|
+
if (spec) {
|
|
39
|
+
const lines = [
|
|
40
|
+
`${spec.name} — ${spec.summary}`,
|
|
41
|
+
'',
|
|
42
|
+
`用法:${spec.usage}`,
|
|
43
|
+
]
|
|
44
|
+
if (spec.endpoints && spec.endpoints.length > 0) {
|
|
45
|
+
lines.push('', '覆盖端点:', ...spec.endpoints.map((e) => ` ${e}`))
|
|
46
|
+
}
|
|
47
|
+
return { text: lines.join('\n'), missing: false }
|
|
48
|
+
}
|
|
49
|
+
// `ch help candidates` 这类只给分组名的查询:列出该组的命令,而不是报「未找到」
|
|
50
|
+
const group = [...commands.values()].filter((s) => s.name.split(' ')[0] === subPath[0])
|
|
51
|
+
if (group.length > 0) {
|
|
52
|
+
const lines = [`[分组 ${subPath[0]}]`, ...group.map((s) => ` ${s.name.padEnd(28)} ${s.summary}`)]
|
|
53
|
+
return { text: lines.join('\n'), missing: false }
|
|
54
|
+
}
|
|
55
|
+
return { text: `未找到命令:${key}\n运行 ch help 查看全部命令`, missing: true }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const groups = new Map()
|
|
59
|
+
for (const spec of commands.values()) {
|
|
60
|
+
const group = spec.name.split(' ')[0]
|
|
61
|
+
if (!groups.has(group)) groups.set(group, [])
|
|
62
|
+
groups.get(group).push(spec)
|
|
63
|
+
}
|
|
64
|
+
const lines = [
|
|
65
|
+
`ch — CloudHouse 招新管理后台 CLI(Agent 优先:stdout 输出 JSON 信封,共 ${commands.size} 条命令)`,
|
|
66
|
+
'',
|
|
67
|
+
'用法:ch [--pretty|--json] [--profile local|prod] [--base-url <url>] <命令> [参数] [--flag 值]',
|
|
68
|
+
'',
|
|
69
|
+
'通用选项:',
|
|
70
|
+
' --pretty 人类可读表格/键值输出(非 TTY 时默认为 JSON)',
|
|
71
|
+
' --json 强制单行 JSON 输出',
|
|
72
|
+
' --no-pretty 强制单行 JSON(管道/重定向时同样生效)',
|
|
73
|
+
' --profile <name> 环境 profile:local | prod(未知 profile 直接 exit 2)',
|
|
74
|
+
' --base-url <url> 直接指定 API 根地址',
|
|
75
|
+
' --timeout <ms> 单次请求超时,1000–600000,默认 30000(上传 120000)',
|
|
76
|
+
' --out <path> 下载落盘位置(仅 uploads 的下载类命令)',
|
|
77
|
+
' --force 允许覆盖已存在的下载目标',
|
|
78
|
+
' --allow-profile-mismatch 显式允许跨环境会话(默认拒绝)',
|
|
79
|
+
' --help 查看某命令用法,如 ch help candidates list;ch help <分组> 看分组',
|
|
80
|
+
'',
|
|
81
|
+
'参数严格性:',
|
|
82
|
+
' 取值型 flag 缺值(--keyword 后无内容)→ exit 2;未知或不适用 flag → exit 2,不发送请求。',
|
|
83
|
+
' 布尔 flag 接受 true|false|1|0|yes|no|y|n|on|off;--flag=值 可安全传递以 - 开头取值。',
|
|
84
|
+
' 多余位置参数 → exit 2;时间/路径/枚举/正整数在本地预检,不合法即 exit 2。',
|
|
85
|
+
' --request-id 仅 capabilities 标注的 AI 写动作支持;资料上传/读操作不支持。',
|
|
86
|
+
'',
|
|
87
|
+
'退出码:',
|
|
88
|
+
' 0 成功 1 业务错误 2 用法错误 3 鉴权失效 4 权限不足 5 限流',
|
|
89
|
+
' 6 版本冲突重试耗尽 7 网络不可达/超时 8 功能开关未开 9 端点未实现',
|
|
90
|
+
'',
|
|
91
|
+
'命令:',
|
|
92
|
+
]
|
|
93
|
+
for (const [group, specs] of groups) {
|
|
94
|
+
lines.push(` [${group}]`)
|
|
95
|
+
for (const spec of specs) lines.push(` ${spec.name.padEnd(28)} ${spec.summary}`)
|
|
96
|
+
}
|
|
97
|
+
lines.push('', '任一未被语义化的端点均可用兜底命令调用:', ' ch api <METHOD> <path> [-q k=v] [-d <json|@file>] [-H "Name: value"] [--public]')
|
|
98
|
+
lines.push('', 'ch api 为逃生舱:flag 解析保持宽松,-d @file 直发文件内容,不做本地预检。')
|
|
99
|
+
lines.push('')
|
|
100
|
+
return { text: lines.join('\n'), missing: false }
|
|
101
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { register } from './index.mjs'
|
|
2
|
+
import { usageError } from '../errors.mjs'
|
|
3
|
+
import { collectQuery, parseNumber } from '../query.mjs'
|
|
4
|
+
import { flagEnum, flagString, flagNumber, payloadFrom } from '../payload.mjs'
|
|
5
|
+
import { optionalReason } from '../sensitive.mjs'
|
|
6
|
+
import { idParam } from '../path.mjs'
|
|
7
|
+
|
|
8
|
+
register('interviews list', {
|
|
9
|
+
summary: '面试名单(按轮次/组别/场次/结果筛选)',
|
|
10
|
+
usage: 'ch interviews list --round 1|2|3 [--group-id N] [--slot-id N] [--result 1|2|3] [--pretty]',
|
|
11
|
+
endpoints: ['GET /admin/interviews'],
|
|
12
|
+
run: async (ctx) => {
|
|
13
|
+
const query = collectQuery(ctx, {
|
|
14
|
+
round: { parse: parseNumber },
|
|
15
|
+
'group-id': { key: 'groupId', parse: parseNumber },
|
|
16
|
+
'slot-id': { key: 'slotId', parse: parseNumber },
|
|
17
|
+
result: { parse: parseNumber },
|
|
18
|
+
})
|
|
19
|
+
if (!new Map(query).has('round')) {
|
|
20
|
+
throw usageError('缺少 --round', '面试名单按轮次查询:ch interviews list --round 1')
|
|
21
|
+
}
|
|
22
|
+
const { data } = await ctx.client.request({ method: 'GET', path: '/admin/interviews', query })
|
|
23
|
+
return data
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
register('interviews search', {
|
|
28
|
+
summary: '跨轮次搜索面试记录与面评关键词',
|
|
29
|
+
usage: 'ch interviews search [--keyword 关键词] [--round N] [--group-id N] [--result 1|2|3] [--pretty]',
|
|
30
|
+
endpoints: ['GET /admin/interviews/search'],
|
|
31
|
+
run: async (ctx) => {
|
|
32
|
+
const query = collectQuery(ctx, {
|
|
33
|
+
keyword: {},
|
|
34
|
+
round: { parse: parseNumber },
|
|
35
|
+
'group-id': { key: 'groupId', parse: parseNumber },
|
|
36
|
+
result: { parse: parseNumber },
|
|
37
|
+
})
|
|
38
|
+
const { data } = await ctx.client.request({ method: 'GET', path: '/admin/interviews/search', query })
|
|
39
|
+
return data
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
register('interviews statistics', {
|
|
44
|
+
summary: '按轮次/组别的面试统计',
|
|
45
|
+
usage: 'ch interviews statistics [--round N] [--group-id N] [--pretty]',
|
|
46
|
+
endpoints: ['GET /admin/interviews/statistics'],
|
|
47
|
+
run: async (ctx) => {
|
|
48
|
+
const query = collectQuery(ctx, {
|
|
49
|
+
round: { parse: parseNumber },
|
|
50
|
+
'group-id': { key: 'groupId', parse: parseNumber },
|
|
51
|
+
})
|
|
52
|
+
const { data } = await ctx.client.request({ method: 'GET', path: '/admin/interviews/statistics', query })
|
|
53
|
+
return data
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
register('interviews reschedule-options', {
|
|
58
|
+
summary: '管理员改约候选项(可改约的场次)',
|
|
59
|
+
usage: 'ch interviews reschedule-options <interviewId>',
|
|
60
|
+
endpoints: ['GET /admin/interviews/{interviewId}/reschedule-options'],
|
|
61
|
+
run: async (ctx) => {
|
|
62
|
+
const interviewId = idParam(ctx.positionals[0], { label: 'interviewId' })
|
|
63
|
+
const { data } = await ctx.client.request({ method: 'GET', path: `/admin/interviews/${interviewId}/reschedule-options` })
|
|
64
|
+
return data
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
register('interviews result', {
|
|
69
|
+
summary: '录入/修改单条面试结果',
|
|
70
|
+
usage: 'ch interviews result <interviewId> --result 1|2|3 [--remark 备注]',
|
|
71
|
+
endpoints: ['PATCH /admin/interviews/{interviewId}/result'],
|
|
72
|
+
run: async (ctx) => {
|
|
73
|
+
const interviewId = idParam(ctx.positionals[0], { label: 'interviewId' })
|
|
74
|
+
const result = flagEnum(ctx, 'result', ['1', '2', '3'], { required: true, label: 'ch interviews result <interviewId> --result 1|2|3' })
|
|
75
|
+
const remark = flagString(ctx, 'remark')
|
|
76
|
+
const base = { result: Number(result) }
|
|
77
|
+
if (remark !== undefined) base.remark = remark
|
|
78
|
+
const payload = payloadFrom(ctx, base)
|
|
79
|
+
const { data } = await ctx.client.request({
|
|
80
|
+
method: 'PATCH',
|
|
81
|
+
path: `/admin/interviews/${interviewId}/result`,
|
|
82
|
+
body: payload,
|
|
83
|
+
})
|
|
84
|
+
return data
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const ATTENDANCE_STATUSES = ['ARRIVED', 'LATE', 'NO_SHOW', 'NOT_CHECKED_IN', 'CANCELLED']
|
|
89
|
+
|
|
90
|
+
register('interviews attendance', {
|
|
91
|
+
summary: '补录/修正面试到场状态',
|
|
92
|
+
usage: 'ch interviews attendance <interviewId> --status ARRIVED|LATE|NO_SHOW|NOT_CHECKED_IN|CANCELLED [--reason 原因]',
|
|
93
|
+
endpoints: ['POST /admin/interviews/{interviewId}/attendance'],
|
|
94
|
+
run: async (ctx) => {
|
|
95
|
+
const interviewId = idParam(ctx.positionals[0], { label: 'interviewId' })
|
|
96
|
+
const status = flagEnum(ctx, 'status', ATTENDANCE_STATUSES, { required: true, label: `--status ${ATTENDANCE_STATUSES.join('|')}` })
|
|
97
|
+
const reason = optionalReason(ctx, { label: '--reason 到场状态修改原因(≤200 字,可省略)' })
|
|
98
|
+
const base = { status }
|
|
99
|
+
if (reason !== undefined) base.reason = reason
|
|
100
|
+
const payload = payloadFrom(ctx, base)
|
|
101
|
+
const { data } = await ctx.client.request({
|
|
102
|
+
method: 'POST',
|
|
103
|
+
path: `/admin/interviews/${interviewId}/attendance`,
|
|
104
|
+
body: payload,
|
|
105
|
+
})
|
|
106
|
+
return data
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
register('interviews reschedule', {
|
|
111
|
+
summary: '管理员为候选人改约场次',
|
|
112
|
+
usage: 'ch interviews reschedule <interviewId> --slot <slotId>',
|
|
113
|
+
endpoints: ['PUT /admin/interviews/{interviewId}/slot'],
|
|
114
|
+
run: async (ctx) => {
|
|
115
|
+
const [interviewId] = ctx.positionals
|
|
116
|
+
if (!interviewId) throw usageError('缺少 interviewId', '用法:ch interviews reschedule <interviewId> --slot 102')
|
|
117
|
+
const slotId = flagNumber(ctx, 'slot', { required: true, label: 'ch interviews reschedule <interviewId> --slot <slotId>' })
|
|
118
|
+
const payload = payloadFrom(ctx, { slotId })
|
|
119
|
+
const { data } = await ctx.client.request({
|
|
120
|
+
method: 'PUT',
|
|
121
|
+
path: `/admin/interviews/${interviewId}/slot`,
|
|
122
|
+
body: payload,
|
|
123
|
+
})
|
|
124
|
+
return data
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
register('interviews batch-result', {
|
|
129
|
+
summary: '批量录入面试结果',
|
|
130
|
+
usage: 'ch interviews batch-result -d <json|@file> # {"round":1,"groupId":2,"results":[{"interviewId":11,"result":1,"remark":"..."}]}',
|
|
131
|
+
endpoints: ['POST /admin/interviews/batch-result'],
|
|
132
|
+
run: async (ctx) => {
|
|
133
|
+
const payload = payloadFrom(ctx, {})
|
|
134
|
+
if (!Number.isFinite(Number(payload.round))) throw usageError('batch-result 载荷缺少数字字段 round')
|
|
135
|
+
if (!Number.isFinite(Number(payload.groupId))) throw usageError('batch-result 载荷缺少数字字段 groupId')
|
|
136
|
+
if (!Array.isArray(payload.results) || payload.results.length === 0) {
|
|
137
|
+
throw usageError('batch-result 载荷的 results 必须是非空数组', '每项:{"interviewId":11,"result":1,"remark":"可选"}')
|
|
138
|
+
}
|
|
139
|
+
for (const item of payload.results) {
|
|
140
|
+
if (!item || !Number.isFinite(Number(item.interviewId)) || ![1, 2, 3].includes(Number(item.result))) {
|
|
141
|
+
throw usageError('results 每项必须包含数字 interviewId 与 result(1|2|3)')
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const { data } = await ctx.client.request({
|
|
145
|
+
method: 'POST',
|
|
146
|
+
path: '/admin/interviews/batch-result',
|
|
147
|
+
body: payload,
|
|
148
|
+
})
|
|
149
|
+
return data
|
|
150
|
+
},
|
|
151
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 命令装配:导入所有命令模块完成注册。
|
|
3
|
+
* 注册表本体在 index.mjs(不依赖任何命令模块,避免 ESM 循环初始化)。
|
|
4
|
+
* 新增命令模块时在此追加一行 import。
|
|
5
|
+
*/
|
|
6
|
+
import './api.mjs'
|
|
7
|
+
import './auth.mjs'
|
|
8
|
+
import './dashboard.mjs'
|
|
9
|
+
import './groups.mjs'
|
|
10
|
+
import './candidates.mjs'
|
|
11
|
+
import './plans.mjs'
|
|
12
|
+
import './slots.mjs'
|
|
13
|
+
import './interviews.mjs'
|
|
14
|
+
import './evaluations.mjs'
|
|
15
|
+
import './notifications.mjs'
|
|
16
|
+
import './assistant.mjs'
|
|
17
|
+
import './uploads.mjs'
|
|
18
|
+
import './accounts.mjs'
|
|
19
|
+
import './applies.mjs'
|
|
20
|
+
import './profile.mjs'
|
|
21
|
+
import './ai.mjs'
|
|
22
|
+
import './feishu.mjs'
|
|
23
|
+
import './checkin.mjs'
|
|
24
|
+
import './extended.mjs'
|
|
25
|
+
import './agent.mjs'
|
|
26
|
+
|
|
27
|
+
import './analysis.mjs'
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { register } from './index.mjs'
|
|
2
|
+
import { usageError } from '../errors.mjs'
|
|
3
|
+
import { collectQuery } from '../query.mjs'
|
|
4
|
+
import { flagEnum, flagString, flagNumber, payloadFrom, camelCase } from '../payload.mjs'
|
|
5
|
+
import { normalizeLocalTime } from '../time.mjs'
|
|
6
|
+
import { idParam } from '../path.mjs'
|
|
7
|
+
|
|
8
|
+
const NOTICE_LEVELS = ['INFO', 'WARNING', 'URGENT']
|
|
9
|
+
const SCOPE_TYPES = ['ALL', 'ROUND', 'GROUP', 'ROUND_GROUP', 'SLOT']
|
|
10
|
+
/** 生效窗口两端;写成常量而非内联字面量,flag 词表守护测试才扫得到 */
|
|
11
|
+
const WINDOW_TIME_FLAGS = ['start-time', 'end-time']
|
|
12
|
+
|
|
13
|
+
/** scopeType 决定哪些 target 字段有意义;缺取值当场报错 */
|
|
14
|
+
function applyScopeTargets(ctx, base, scopeType) {
|
|
15
|
+
if (scopeType === 'ROUND' || scopeType === 'ROUND_GROUP') {
|
|
16
|
+
base.targetRound = flagNumber(ctx, 'target-round', { required: true, label: '--target-round N' })
|
|
17
|
+
}
|
|
18
|
+
if (scopeType === 'GROUP' || scopeType === 'ROUND_GROUP') {
|
|
19
|
+
base.targetGroupId = flagNumber(ctx, 'target-group-id', { required: true, label: '--target-group-id N' })
|
|
20
|
+
}
|
|
21
|
+
if (scopeType === 'SLOT') {
|
|
22
|
+
base.targetSlotId = flagNumber(ctx, 'target-slot-id', { required: true, label: '--target-slot-id N' })
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 创建通知:未用到的 target / 时间显式置 null(INSERT 需要把这些列写成空值)。
|
|
28
|
+
*/
|
|
29
|
+
function createPayload(ctx) {
|
|
30
|
+
const base = { targetRound: null, targetGroupId: null, targetSlotId: null }
|
|
31
|
+
base.title = flagString(ctx, 'title', { required: true, label: 'ch notifications create --title 标题' })
|
|
32
|
+
base.content = flagString(ctx, 'content', { required: true, label: 'ch notifications create --content 内容' })
|
|
33
|
+
base.noticeLevel = flagEnum(ctx, 'notice-level', NOTICE_LEVELS, { required: true, label: `--notice-level ${NOTICE_LEVELS.join('|')}` })
|
|
34
|
+
base.scopeType = flagEnum(ctx, 'scope-type', SCOPE_TYPES, { required: true, label: `--scope-type ${SCOPE_TYPES.join('|')}` })
|
|
35
|
+
applyScopeTargets(ctx, base, base.scopeType)
|
|
36
|
+
for (const flag of WINDOW_TIME_FLAGS) {
|
|
37
|
+
const value = flagString(ctx, flag)
|
|
38
|
+
base[camelCase(flag)] = value === undefined ? null : normalizeLocalTime(value)
|
|
39
|
+
}
|
|
40
|
+
const status = flagEnum(ctx, 'status', ['1', '2'])
|
|
41
|
+
if (status !== undefined) base.status = Number(status)
|
|
42
|
+
return payloadFrom(ctx, base)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 编辑通知:只带显式给出的字段。
|
|
47
|
+
* 后端 InterviewNotificationServiceImpl:179-200 对每个字段都是「非空才 set」,
|
|
48
|
+
* 传 null 等于不传;所以旧写法无 flag 时发出的那五个 null 既清不掉任何东西,
|
|
49
|
+
* 也不会更新任何列——一条纯粹的空写请求。
|
|
50
|
+
*/
|
|
51
|
+
function updatePayload(ctx) {
|
|
52
|
+
const base = {}
|
|
53
|
+
const title = flagString(ctx, 'title')
|
|
54
|
+
if (title !== undefined) base.title = title
|
|
55
|
+
const content = flagString(ctx, 'content')
|
|
56
|
+
if (content !== undefined) base.content = content
|
|
57
|
+
const noticeLevel = flagEnum(ctx, 'notice-level', NOTICE_LEVELS)
|
|
58
|
+
if (noticeLevel !== undefined) base.noticeLevel = noticeLevel
|
|
59
|
+
const scopeType = flagEnum(ctx, 'scope-type', SCOPE_TYPES)
|
|
60
|
+
if (scopeType !== undefined) base.scopeType = scopeType
|
|
61
|
+
applyScopeTargets(ctx, base, scopeType)
|
|
62
|
+
for (const flag of WINDOW_TIME_FLAGS) {
|
|
63
|
+
const value = flagString(ctx, flag)
|
|
64
|
+
if (value !== undefined) base[camelCase(flag)] = normalizeLocalTime(value)
|
|
65
|
+
}
|
|
66
|
+
const status = flagEnum(ctx, 'status', ['1', '2'])
|
|
67
|
+
if (status !== undefined) base.status = Number(status)
|
|
68
|
+
const payload = payloadFrom(ctx, base)
|
|
69
|
+
if (Object.keys(payload).length === 0) {
|
|
70
|
+
throw usageError('至少提供一个修改字段', '--title / --content / --notice-level / --scope-type / --target-* / --start-time / --end-time / --status')
|
|
71
|
+
}
|
|
72
|
+
return payload
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
register('notifications list', {
|
|
76
|
+
summary: '横幅通知列表',
|
|
77
|
+
usage: 'ch notifications list [--scope-type ALL|ROUND|GROUP|ROUND_GROUP|SLOT] [--status 1|2] [--pretty]',
|
|
78
|
+
endpoints: ['GET /admin/notifications'],
|
|
79
|
+
run: async (ctx) => {
|
|
80
|
+
const query = collectQuery(ctx, { 'scope-type': { key: 'scopeType' }, status: {} })
|
|
81
|
+
const { data } = await ctx.client.request({ method: 'GET', path: '/admin/notifications', query })
|
|
82
|
+
return data
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
register('notifications create', {
|
|
87
|
+
summary: '发布横幅通知',
|
|
88
|
+
usage: 'ch notifications create --title 标题 --content 内容 --notice-level INFO|WARNING|URGENT --scope-type ALL|ROUND|GROUP|ROUND_GROUP|SLOT [--target-round N] [--target-group-id N] [--target-slot-id N] [--start-time "..."] [--end-time "..."] [--status 1|2]',
|
|
89
|
+
endpoints: ['POST /admin/notifications'],
|
|
90
|
+
run: async (ctx) => {
|
|
91
|
+
const payload = createPayload(ctx)
|
|
92
|
+
const { data } = await ctx.client.request({ method: 'POST', path: '/admin/notifications', body: payload })
|
|
93
|
+
return data
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
register('notifications update', {
|
|
98
|
+
summary: '编辑横幅通知',
|
|
99
|
+
usage: 'ch notifications update <id> [--title ...] [--content ...] [--notice-level ...] [--scope-type ...] [--start-time ...] [--end-time ...]',
|
|
100
|
+
endpoints: ['PUT /admin/notifications/{id}'],
|
|
101
|
+
run: async (ctx) => {
|
|
102
|
+
const id = idParam(ctx.positionals[0], { label: '通知 id' })
|
|
103
|
+
const payload = updatePayload(ctx)
|
|
104
|
+
const { data } = await ctx.client.request({ method: 'PUT', path: `/admin/notifications/${id}`, body: payload })
|
|
105
|
+
return data === null ? { updated: true, id: Number(id) } : data
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
register('notifications status', {
|
|
110
|
+
summary: '上/下线路横幅通知',
|
|
111
|
+
usage: 'ch notifications status <id> --status 1|2',
|
|
112
|
+
endpoints: ['PATCH /admin/notifications/{id}/status'],
|
|
113
|
+
run: async (ctx) => {
|
|
114
|
+
const id = idParam(ctx.positionals[0], { label: '通知 id' })
|
|
115
|
+
const status = flagEnum(ctx, 'status', ['1', '2'], { required: true, label: 'ch notifications status <id> --status 1|2' })
|
|
116
|
+
const payload = payloadFrom(ctx, { status: Number(status) })
|
|
117
|
+
const { data } = await ctx.client.request({ method: 'PATCH', path: `/admin/notifications/${id}/status`, body: payload })
|
|
118
|
+
return data === null ? { updated: true, id: Number(id) } : data
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
register('notifications delete', {
|
|
123
|
+
summary: '删除横幅通知',
|
|
124
|
+
usage: 'ch notifications delete <id>',
|
|
125
|
+
endpoints: ['DELETE /admin/notifications/{id}'],
|
|
126
|
+
run: async (ctx) => {
|
|
127
|
+
const id = idParam(ctx.positionals[0], { label: '通知 id' })
|
|
128
|
+
const { data } = await ctx.client.request({ method: 'DELETE', path: `/admin/notifications/${id}` })
|
|
129
|
+
return data === null ? { deleted: true, id: Number(id) } : data
|
|
130
|
+
},
|
|
131
|
+
})
|