@weotro/dx 0.1.21 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +76 -768
  2. package/bin/dx.js +100 -182
  3. package/example/.env.development +1 -0
  4. package/example/README.md +24 -0
  5. package/example/dx/scripts/build +2 -0
  6. package/example/dx/scripts/db +2 -0
  7. package/example/dx/scripts/deploy +2 -0
  8. package/example/dx/scripts/setup +2 -0
  9. package/example/dx/scripts/start +12 -0
  10. package/example/dx/scripts/test +2 -0
  11. package/lib/env.js +40 -257
  12. package/lib/logger.js +5 -238
  13. package/lib/setup.js +72 -0
  14. package/package.json +27 -13
  15. package/skills/ask_3th/SKILL.md +2 -0
  16. package/skills/delegate_3th/SKILL.md +4 -2
  17. package/skills/delegate_3th/scripts/delegate_3th.py +37 -41
  18. package/skills/delegate_3th/scripts/test_delegate_3th.py +43 -17
  19. package/bin/dx-with-version-env.js +0 -8
  20. package/lib/artifact-deploy/artifact-builder.js +0 -144
  21. package/lib/artifact-deploy/config.js +0 -180
  22. package/lib/artifact-deploy/remote-script.js +0 -301
  23. package/lib/artifact-deploy/remote-transport.js +0 -86
  24. package/lib/artifact-deploy.js +0 -70
  25. package/lib/backend-artifact-deploy/artifact-builder.js +0 -267
  26. package/lib/backend-artifact-deploy/config.js +0 -218
  27. package/lib/backend-artifact-deploy/path-utils.js +0 -18
  28. package/lib/backend-artifact-deploy/remote-phases.js +0 -14
  29. package/lib/backend-artifact-deploy/remote-result.js +0 -44
  30. package/lib/backend-artifact-deploy/remote-script.js +0 -507
  31. package/lib/backend-artifact-deploy/remote-transport.js +0 -123
  32. package/lib/backend-artifact-deploy/rollback.js +0 -5
  33. package/lib/backend-artifact-deploy/runtime-package.js +0 -46
  34. package/lib/backend-artifact-deploy.js +0 -91
  35. package/lib/backend-package.js +0 -674
  36. package/lib/cli/args.js +0 -38
  37. package/lib/cli/command-result.js +0 -1
  38. package/lib/cli/commands/contracts.js +0 -60
  39. package/lib/cli/commands/core.js +0 -533
  40. package/lib/cli/commands/db.js +0 -231
  41. package/lib/cli/commands/deploy.js +0 -175
  42. package/lib/cli/commands/env.js +0 -120
  43. package/lib/cli/commands/export.js +0 -39
  44. package/lib/cli/commands/package.js +0 -22
  45. package/lib/cli/commands/release.js +0 -55
  46. package/lib/cli/commands/stack.js +0 -427
  47. package/lib/cli/commands/start.js +0 -58
  48. package/lib/cli/commands/worktree.js +0 -145
  49. package/lib/cli/dx-cli.js +0 -1098
  50. package/lib/cli/flags.js +0 -123
  51. package/lib/cli/help-model.js +0 -234
  52. package/lib/cli/help-renderer.js +0 -137
  53. package/lib/cli/help-schema.js +0 -552
  54. package/lib/cli/help.js +0 -141
  55. package/lib/cli/index.js +0 -4
  56. package/lib/cli/nx-command.js +0 -13
  57. package/lib/confirm.js +0 -213
  58. package/lib/env-policy.js +0 -134
  59. package/lib/env-profile.js +0 -531
  60. package/lib/exec.js +0 -698
  61. package/lib/nx-ignore.js +0 -45
  62. package/lib/run-with-version-env.js +0 -163
  63. package/lib/sdk-build.js +0 -424
  64. package/lib/start-dev.js +0 -401
  65. package/lib/telegram-webhook.js +0 -431
  66. package/lib/validate-env.js +0 -317
  67. package/lib/vercel-deploy.js +0 -549
  68. package/lib/worktree.js +0 -1052
  69. package/skills/delegate_3th/scripts/__pycache__/delegate_3th.cpython-314.pyc +0 -0
  70. package/skills/delegate_3th/scripts/__pycache__/delegate_cc.cpython-314.pyc +0 -0
  71. package/skills/delegate_3th/scripts/__pycache__/test_delegate_cc.cpython-314.pyc +0 -0
package/lib/logger.js CHANGED
@@ -1,239 +1,6 @@
1
- import { writeFileSync, mkdirSync } from 'node:fs'
2
- import { join } from 'node:path'
3
-
4
- function resolveProjectRoot() {
5
- return process.env.DX_PROJECT_ROOT || process.cwd()
1
+ export const logger = {
2
+ error(message) { process.stderr.write(`dx: ${message}\n`) },
3
+ warn(message) { process.stderr.write(`dx: ${message}\n`) },
4
+ info(message) { process.stdout.write(`${message}\n`) },
5
+ success(message) { process.stdout.write(`${message}\n`) },
6
6
  }
7
-
8
- export function sanitizeForLog(input) {
9
- let text = input == null ? '' : String(input)
10
-
11
- // CLI token args (vercel)
12
- text = text.replace(/--token=("[^"]*"|'[^']*'|[^\s]+)/gi, '--token=***')
13
- text = text.replace(/--token\s+("[^"]*"|'[^']*'|[^\s]+)/gi, '--token ***')
14
-
15
- // Env style secrets
16
- text = text.replace(/\bVERCEL_TOKEN=([^\s]+)/g, 'VERCEL_TOKEN=***')
17
- text = text.replace(/\bTELEGRAM_BOT_TOKEN=([^\s]+)/g, 'TELEGRAM_BOT_TOKEN=***')
18
- text = text.replace(
19
- /\bTELEGRAM_BOT_WEBHOOK_SECRET=([^\s]+)/g,
20
- 'TELEGRAM_BOT_WEBHOOK_SECRET=***',
21
- )
22
-
23
- // Authorization bearer
24
- text = text.replace(
25
- /Authorization:\s*Bearer\s+([^\s]+)/gi,
26
- 'Authorization: Bearer ***',
27
- )
28
-
29
- // JSON-ish token fields
30
- text = text.replace(/"token"\s*:\s*"[^"]*"/gi, '"token":"***"')
31
- text = text.replace(
32
- /("secret_token"\s*:\s*")([^"]*)(")/gi,
33
- '$1***$3',
34
- )
35
- text = text.replace(/\bsecret_token=([^\s&]+)/gi, 'secret_token=***')
36
-
37
- // Telegram bot token in URLs
38
- text = text.replace(
39
- /api\.telegram\.org\/bot([^/\s]+)(\/|$)/gi,
40
- 'api.telegram.org/bot***$2',
41
- )
42
-
43
- return text
44
- }
45
-
46
- // 处理输出管道被关闭导致的 EPIPE 错误,避免进程在清理阶段崩溃
47
- try {
48
- const handleBrokenPipe = err => {
49
- if (err && (err.code === 'EPIPE' || err.code === 'ERR_STREAM_WRITE_AFTER_END')) {
50
- // 静默忽略,允许进程优雅退出
51
- try {
52
- /* noop */
53
- } catch {}
54
- }
55
- }
56
- if (process?.stdout?.on) process.stdout.on('error', handleBrokenPipe)
57
- if (process?.stderr?.on) process.stderr.on('error', handleBrokenPipe)
58
- } catch {
59
- /* 安全保护,忽略环境不支持的情况 */
60
- }
61
-
62
- export class Logger {
63
- constructor(options = {}) {
64
- this.logLevel = options.level || 'info'
65
- this.enableFile = options.enableFile || false
66
- this.logDir = options.logDir || join(resolveProjectRoot(), 'dx', 'logs')
67
-
68
- if (this.enableFile) {
69
- this.ensureLogDir()
70
- }
71
- }
72
-
73
- // 确保日志目录存在
74
- ensureLogDir() {
75
- try {
76
- mkdirSync(this.logDir, { recursive: true })
77
- } catch (error) {
78
- // 目录可能已存在,忽略错误
79
- }
80
- }
81
-
82
- // 格式化时间戳
83
- formatTimestamp() {
84
- return new Date().toLocaleString('zh-CN')
85
- }
86
-
87
- // 基础日志方法
88
- info(message, prefix = '🚀') {
89
- const safeMessage = sanitizeForLog(message)
90
- const output = `${prefix} ${safeMessage}`
91
- console.log(output)
92
- this.writeLog('info', safeMessage)
93
- }
94
-
95
- success(message) {
96
- const safeMessage = sanitizeForLog(message)
97
- const output = `✅ ${safeMessage}`
98
- console.log(output)
99
- this.writeLog('success', safeMessage)
100
- }
101
-
102
- warn(message) {
103
- const safeMessage = sanitizeForLog(message)
104
- const output = `⚠️ ${safeMessage}`
105
- console.log(output)
106
- this.writeLog('warn', safeMessage)
107
- }
108
-
109
- error(message) {
110
- const safeMessage = sanitizeForLog(message)
111
- const output = `❌ ${safeMessage}`
112
- console.log(output)
113
- this.writeLog('error', safeMessage)
114
- }
115
-
116
- debug(message) {
117
- if (this.logLevel === 'debug') {
118
- const safeMessage = sanitizeForLog(message)
119
- const output = `🐛 ${safeMessage}`
120
- console.log(output)
121
- this.writeLog('debug', safeMessage)
122
- }
123
- }
124
-
125
- // 步骤显示
126
- step(message, stepNumber = null) {
127
- const prefix = stepNumber ? `步骤 ${stepNumber}:` : '执行:'
128
- const separator = '=================================='
129
-
130
- const safeMessage = sanitizeForLog(message)
131
-
132
- console.log(`\n${separator}`)
133
- console.log(`🚀 ${prefix} ${safeMessage}`)
134
- console.log(separator)
135
-
136
- this.writeLog('step', `${prefix} ${safeMessage}`)
137
- }
138
-
139
- // 进度显示
140
- progress(message) {
141
- const safeMessage = sanitizeForLog(message)
142
- process.stdout.write(`⌛ ${safeMessage}...`)
143
- this.writeLog('progress', `开始: ${safeMessage}`)
144
- }
145
-
146
- progressDone() {
147
- console.log(' 完成')
148
- this.writeLog('progress', '完成')
149
- }
150
-
151
- // 命令执行日志
152
- command(command) {
153
- const safeCommand = sanitizeForLog(command)
154
- console.log(`💻 执行: ${safeCommand}`)
155
- this.writeLog('command', safeCommand)
156
- }
157
-
158
- // 分隔符
159
- separator() {
160
- console.log(`\n${'='.repeat(50)}`)
161
- }
162
-
163
- // 表格显示
164
- table(data, headers = []) {
165
- if (data.length === 0) return
166
-
167
- if (headers.length > 0) {
168
- const safeHeaders = headers.map(h => sanitizeForLog(h))
169
- console.log(`\n${safeHeaders.join('\t')}`)
170
- console.log('-'.repeat(safeHeaders.join('\t').length))
171
- }
172
-
173
- data.forEach(row => {
174
- if (Array.isArray(row)) {
175
- console.log(row.map(cell => sanitizeForLog(cell)).join('\t'))
176
- } else {
177
- console.log(sanitizeForLog(row))
178
- }
179
- })
180
- console.log()
181
- }
182
-
183
- // 端口信息显示
184
- ports(portInfo) {
185
- console.log('\n📡 服务端口信息:')
186
- portInfo.forEach(({ service, port, url }) => {
187
- const safeService = sanitizeForLog(service)
188
- const safeUrl = url ? sanitizeForLog(url) : ''
189
- console.log(` ${safeService}: http://localhost:${port} ${safeUrl ? `(${safeUrl})` : ''}`)
190
- })
191
- console.log()
192
- }
193
-
194
- // 写入日志文件
195
- writeLog(level, message) {
196
- if (!this.enableFile) return
197
-
198
- try {
199
- const safeMessage = sanitizeForLog(message)
200
- const timestamp = this.formatTimestamp()
201
- const logLine = `[${timestamp}] [${level.toUpperCase()}] ${safeMessage}\n`
202
-
203
- const logFile = join(this.logDir, `dx-${new Date().toISOString().split('T')[0]}.log`)
204
- writeFileSync(logFile, logLine, { flag: 'a', encoding: 'utf8' })
205
- } catch (error) {
206
- // 写入日志失败时静默处理,避免影响主流程
207
- }
208
- }
209
-
210
- // 创建子日志器
211
- createChild(prefix) {
212
- const childLogger = new Logger({
213
- level: this.logLevel,
214
- enableFile: this.enableFile,
215
- logDir: this.logDir,
216
- })
217
-
218
- // 重写方法以添加前缀
219
- const originalMethods = ['info', 'success', 'warn', 'error', 'debug']
220
- originalMethods.forEach(method => {
221
- const originalMethod = childLogger[method].bind(childLogger)
222
- childLogger[method] = (message, customPrefix) => {
223
- const finalPrefix = customPrefix || prefix
224
- originalMethod(message, finalPrefix)
225
- }
226
- })
227
-
228
- return childLogger
229
- }
230
- }
231
-
232
- // 导出默认实例
233
- export const logger = new Logger()
234
-
235
- // 导出带文件日志的实例
236
- export const fileLogger = new Logger({ enableFile: true })
237
-
238
- // 导出调试日志实例
239
- export const debugLogger = new Logger({ level: 'debug', enableFile: true })
package/lib/setup.js ADDED
@@ -0,0 +1,72 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { readdirSync, lstatSync, mkdirSync, copyFileSync, realpathSync, constants } from 'node:fs'
3
+ import { join, dirname, relative, resolve, sep } from 'node:path'
4
+
5
+ function git(root, args, allowOutsideRepository = false) {
6
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')))
7
+ const result = spawnSync('git', args, {
8
+ cwd: root, env: { ...env, LC_ALL: 'C', GIT_OPTIONAL_LOCKS: '0' },
9
+ encoding: 'utf8', timeout: 10000, maxBuffer: 4 * 1024 * 1024,
10
+ })
11
+ if (allowOutsideRepository && result.status === 128 && result.stderr?.includes('not a git repository')) return null
12
+ if (result.error || result.status !== 0) {
13
+ throw new Error('基础 setup 无法读取 Git 工作目录;请检查 Git 已安装且当前仓库的 worktree 信息有效。')
14
+ }
15
+ return result.stdout
16
+ }
17
+
18
+ function entryAt(path) {
19
+ try { return lstatSync(path) } catch (error) {
20
+ if (error.code === 'ENOENT') return null
21
+ throw error
22
+ }
23
+ }
24
+
25
+ function prepareParent(root, destination) {
26
+ let directory = root
27
+ for (const part of relative(root, dirname(destination)).split(sep).filter(Boolean)) {
28
+ directory = join(directory, part)
29
+ try { mkdirSync(directory) } catch (error) {
30
+ if (error.code !== 'EEXIST') throw error
31
+ }
32
+ if (!lstatSync(directory).isDirectory()) {
33
+ throw new Error(`基础 setup 的目标父路径 ${directory} 不是普通目录;请移除路径冲突后重试。`)
34
+ }
35
+ }
36
+ }
37
+
38
+ export function runBaseSetup(projectRoot) {
39
+ const top = git(projectRoot, ['rev-parse', '--show-toplevel'], true)
40
+ if (top === null) return
41
+ const destinationRoot = realpathSync(top.replace(/\r?\n$/, ''))
42
+ const records = git(projectRoot, ['worktree', 'list', '--porcelain', '-z'])
43
+ .split('\0\0').filter(Boolean).map(record => record.split('\0'))
44
+ // Git lists the main worktree first; a bare repository has no source checkout.
45
+ if (records[0]?.includes('bare')) return
46
+ const paths = records.map(record => record.find(field => field.startsWith('worktree '))?.slice(9))
47
+ if (!paths[0]) throw new Error('基础 setup 无法定位主 Git 工作目录;请检查 git worktree list。')
48
+ const sourceRoot = realpathSync(paths[0])
49
+ if (sourceRoot === destinationRoot) return
50
+ const worktrees = new Set(paths.filter(Boolean).map(path => resolve(path)))
51
+
52
+ function copyDirectory(directory) {
53
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
54
+ if (entry.name === '.git') continue
55
+ const source = join(directory, entry.name)
56
+ if (entry.isDirectory()) {
57
+ if (!worktrees.has(source)) copyDirectory(source)
58
+ } else if (entry.isFile() && entry.name.endsWith('.local')) {
59
+ const destination = join(destinationRoot, relative(sourceRoot, source))
60
+ prepareParent(destinationRoot, destination)
61
+ if (entryAt(destination)) continue
62
+ try { copyFileSync(source, destination, constants.COPYFILE_EXCL) } catch (error) {
63
+ if (error.code !== 'EEXIST') throw error
64
+ }
65
+ }
66
+ }
67
+ }
68
+ try { copyDirectory(sourceRoot) } catch (error) {
69
+ if (!error.code) throw error
70
+ throw new Error(`基础 setup 复制 .local 文件失败(${error.code});请检查 ${sourceRoot} 与 ${destinationRoot} 的文件类型和权限后重试。`)
71
+ }
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weotro/dx",
3
- "version": "0.1.21",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -12,13 +12,24 @@
12
12
  },
13
13
  "homepage": "https://github.com/rangershi/dx#readme",
14
14
  "bin": {
15
- "dx": "bin/dx.js",
16
- "dx-with-version-env": "bin/dx-with-version-env.js"
15
+ "dx": "bin/dx.js"
17
16
  },
18
17
  "files": [
19
- "bin/",
20
- "lib/",
21
- "skills/",
18
+ "bin/dx.js",
19
+ "lib/logger.js",
20
+ "lib/version.js",
21
+ "lib/env.js",
22
+ "lib/setup.js",
23
+ "lib/codex-initial.js",
24
+ "skills",
25
+ "example/README.md",
26
+ "example/.env.development",
27
+ "example/dx/scripts/setup",
28
+ "example/dx/scripts/start",
29
+ "example/dx/scripts/build",
30
+ "example/dx/scripts/test",
31
+ "example/dx/scripts/db",
32
+ "example/dx/scripts/deploy",
22
33
  "LICENSE",
23
34
  "README.md",
24
35
  "package.json"
@@ -26,13 +37,9 @@
26
37
  "publishConfig": {
27
38
  "access": "public"
28
39
  },
29
- "dependencies": {
30
- "strip-json-comments": "^5.0.3",
31
- "yaml": "^2.8.1"
32
- },
33
40
  "devDependencies": {
34
41
  "jest": "^29.7.0",
35
- "nx": "22.4.4"
42
+ "yaml": "^2.9.0"
36
43
  },
37
44
  "scripts": {
38
45
  "test": "NODE_OPTIONS=--experimental-vm-modules jest"
@@ -40,6 +47,13 @@
40
47
  "engines": {
41
48
  "node": ">=20.11.0"
42
49
  },
43
- "nx": {},
44
- "packageManager": "pnpm@10.30.0+sha512.2b5753de015d480eeb88f5b5b61e0051f05b4301808a82ec8b840c9d2adf7748eb352c83f5c1593ca703ff1017295bc3fdd3119abb9686efc96b9fcb18200937"
50
+ "packageManager": "pnpm@10.30.0+sha512.2b5753de015d480eeb88f5b5b61e0051f05b4301808a82ec8b840c9d2adf7748eb352c83f5c1593ca703ff1017295bc3fdd3119abb9686efc96b9fcb18200937",
51
+ "os": [
52
+ "darwin",
53
+ "linux"
54
+ ],
55
+ "dependencies": {
56
+ "dotenv": "^17.4.2",
57
+ "dotenv-expand": "^13.0.0"
58
+ }
45
59
  }
@@ -20,6 +20,8 @@ description: 显式调用 ask_3th 或被已授权流程调用时,向第三方
20
20
 
21
21
  仅在上述路由选择 Claude 时使用当前技能目录的 `scripts/ask_3th.py`;它固定为咨询模式,调用细节与失败状态由 `delegate_3th` 维护。脚本自身不能启动宿主 sub-agent,收到失败状态后由执行本技能的 agent 完成回退。
22
22
 
23
+ 在 Codex 宿主下默认不传模型参数,由 Claude Code CLI 按当前生效的用户/项目配置及环境变量选择模型。调用方不要补上固定模型 ID;返回中的 `requested_model: null` 表示使用 CLI 默认选择,实际模型未知时不要推测。
24
+
23
25
  ```bash
24
26
  python3 "$HOME/.agents/skills/ask_3th/scripts/ask_3th.py" --prompt-file /绝对路径/问题.txt
25
27
  ```
@@ -42,13 +42,15 @@ python3 "$HOME/.agents/skills/delegate_3th/scripts/delegate_3th.py" --prompt-fil
42
42
  python3 "$HOME/.agents/skills/delegate_3th/scripts/delegate_3th.py" --mode task --cwd /绝对路径/工作区 --prompt-file /绝对路径/任务.txt
43
43
  ```
44
44
 
45
- 默认请求 `claude-fable-5-1`。兼容参数 `--start-model` 仅选择本次任务请求的模型,不构成重试链。需要运行验证命令时按已有授权追加具体的 `--allow-command '仓库实际验证命令'`,可重复传入。
45
+ 默认不传 `--model`,由 Claude Code CLI 使用该次调用实际生效的用户/项目配置及环境变量选择模型;技能不硬编码模型,也不读取或覆盖模型配置。Codex 宿主下的咨询与执行均遵循此规则,用户调整 Claude Code 默认模型后,后续调用随之生效。
46
+
47
+ 仅在用户明确指定本次任务模型时,使用任务模式的 `--start-model <模型 ID 或别名>`;脚本将其原样传给 Claude 的 `--model`,不限制固定模型名单,也不构成重试链。需要运行验证命令时按已有授权追加具体的 `--allow-command '仓库实际验证命令'`,可重复传入。
46
48
 
47
49
  执行模式开放 `Read,Glob,Grep,Edit,Write,Bash`,使用 `acceptEdits`,无法获批的工具请求自动拒绝;现有权限规则继续生效,允许命令不是独立沙箱。两种模式关闭 hooks、MCP 和技能,因此任务书必须包含必要项目规则。脚本默认仅执行本地开发;完整交付等超出该边界的任务直接进入 fresh sub-agent,由其承接明确授权。
48
50
 
49
51
  通过宿主异步工具保留会话 ID 并持续读取结果,至少每 60 秒汇报有意义的进度。`--timeout` 只控制无活动告警,不自动终止任务。脚本通过输出字节和 Git 跟踪及未忽略文件的变化观察活动;忽略目录产物需另查,观测不可用不代表停滞。
50
52
 
51
- 脚本 stdout 返回单个 JSON:`ok` 表示咨询已返回,`review_required` 表示任务报告待验收,`fallback_required` 表示立即进入下文回退。脚本只报告失败,启动宿主 sub-agent 和等待结果是本技能执行者的职责。
53
+ 脚本 stdout 返回单个 JSON:`ok` 表示咨询已返回,`review_required` 表示任务报告待验收,`fallback_required` 表示立即进入下文回退。`requested_model: null` 表示未覆盖 CLI 模型配置,不能据此声称实际使用了某个模型;显式指定时记录该请求值。脚本只报告失败,启动宿主 sub-agent 和等待结果是本技能执行者的职责。
52
54
 
53
55
  ### Codex 插件
54
56
 
@@ -13,9 +13,6 @@ import sys
13
13
  import time
14
14
 
15
15
 
16
- MODELS = ("claude-fable-5-1", "claude-opus-5")
17
-
18
-
19
16
  def workspace_activity(cwd):
20
17
  """记录文件本身的变化,同一脏文件继续编辑也算活动。"""
21
18
  if cwd is None:
@@ -83,42 +80,42 @@ def ask(prompt, timeout=120, *, mode="consult", cwd=None, start_model=None, allo
83
80
  raise ValueError("代码任务必须指定存在的工作目录")
84
81
  if mode == "consult" and (start_model is not None or allow_commands):
85
82
  raise ValueError("咨询模式不接受任务专用参数")
86
- attempts = []
83
+ if start_model is not None and (not isinstance(start_model, str) or not start_model.strip()):
84
+ raise ValueError("显式模型名称不能为空")
87
85
  cli = shutil.which("claude")
88
86
  if not cli:
89
87
  return {"status": "fallback_required", "attempts": [], "reason": "未找到 claude 命令",
90
88
  "next_executor": "fresh_subagent", "partial_work_possible": False}
91
- models = (start_model or MODELS[0],)
92
- for model in models:
93
- if model not in MODELS:
94
- raise ValueError("模型不在委派链中")
95
- print(f"正在调用 {model}({mode})…", file=sys.stderr, flush=True)
96
- command = [
97
- cli, "--print", "--model", model, "--output-format", "stream-json",
98
- "--verbose", "--include-partial-messages",
99
- "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
100
- "--disable-slash-commands", "--no-session-persistence",
101
- "--settings", '{"disableAllHooks":true}',
89
+ model = start_model
90
+ print(f"正在调用 {model or 'Claude Code 配置的默认模型'}({mode})…", file=sys.stderr, flush=True)
91
+ command = [
92
+ cli, "--print", "--output-format", "stream-json",
93
+ "--verbose", "--include-partial-messages",
94
+ "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
95
+ "--disable-slash-commands", "--no-session-persistence",
96
+ "--settings", '{"disableAllHooks":true}',
97
+ ]
98
+ if model is not None:
99
+ command += ["--model", model]
100
+ if mode == "consult":
101
+ command += ["--tools", "", "--system-prompt", "你是决策与问答顾问。根据提供的事实用简体中文回答,说明关键理由和不确定性。仅提供建议。"]
102
+ else:
103
+ command += [
104
+ "--tools", "Read,Glob,Grep,Edit,Write,Bash",
105
+ "--permission-mode", "acceptEdits", "--permission-prompts", "none",
106
+ "--append-system-prompt",
107
+ "你是协作开发者。按任务书实际修改代码并验证。你不是唯一的执行者,保留其他人的改动,遵守分配的文件范围。"
108
+ "先阅读适用的 AGENTS.md 和 CLAUDE.md;接续任务先检查当前状态,仅完成剩余工作。"
109
+ "仅执行本地开发和验证,不提交、推送、发布或对外发消息。权限被拒绝时报告具体阻塞,不尝试绕过。"
110
+ "完成后用简体中文报告变更文件、验证命令及结果、未完成项。",
102
111
  ]
103
- if mode == "consult":
104
- command += ["--tools", "", "--system-prompt", "你是决策与问答顾问。根据提供的事实用简体中文回答,说明关键理由和不确定性。仅提供建议。"]
105
- else:
106
- command += [
107
- "--tools", "Read,Glob,Grep,Edit,Write,Bash",
108
- "--permission-mode", "acceptEdits", "--permission-prompts", "none",
109
- "--append-system-prompt",
110
- "你是协作开发者。按任务书实际修改代码并验证。你不是唯一的执行者,保留其他人的改动,遵守分配的文件范围。"
111
- "先阅读适用的 AGENTS.md 和 CLAUDE.md;接续任务先检查当前状态,仅完成剩余工作。"
112
- "仅执行本地开发和验证,不提交、推送、发布或对外发消息。权限被拒绝时报告具体阻塞,不尝试绕过。"
113
- "完成后用简体中文报告变更文件、验证命令及结果、未完成项。",
114
- ]
115
- if allow_commands:
116
- command += ["--allowedTools", *[f"Bash({item})" for item in allow_commands]]
117
- try:
118
- process = run_task(command, prompt, timeout, cwd)
119
- except OSError as error:
120
- attempts.append({"requested_model": model, "reason": f"无法运行 CLI:{type(error).__name__}"})
121
- break
112
+ if allow_commands:
113
+ command += ["--allowedTools", *[f"Bash({item})" for item in allow_commands]]
114
+ try:
115
+ process = run_task(command, prompt, timeout, cwd)
116
+ except OSError as error:
117
+ reason = f"无法运行 CLI:{type(error).__name__}"
118
+ else:
122
119
  try:
123
120
  payload = {}
124
121
  for line in process.stdout.splitlines():
@@ -136,7 +133,7 @@ def ask(prompt, timeout=120, *, mode="consult", cwd=None, start_model=None, allo
136
133
  and isinstance(answer, str) and answer.strip()):
137
134
  return {"status": "review_required" if mode == "task" else "ok", "requested_model": model,
138
135
  "permission_denials": len(payload.get("permission_denials") or []),
139
- "answer": answer.strip(), "attempts": attempts}
136
+ "answer": answer.strip(), "attempts": []}
140
137
  # 原始错误可能包含账户信息,仅输出有界的分类原因。
141
138
  diagnostic = (process.stderr + process.stdout).lower()
142
139
  if any(word in diagnostic for word in ("quota", "credit", "usage limit", "rate_limit", "rate limit", "429")):
@@ -147,11 +144,8 @@ def ask(prompt, timeout=120, *, mode="consult", cwd=None, start_model=None, allo
147
144
  reason = "模型名称无效或当前账户无权访问"
148
145
  else:
149
146
  reason = f"CLI 未返回有效答案(退出码 {process.returncode})"
150
- attempts.append({"requested_model": model, "reason": reason})
151
- result = {"status": "fallback_required", "attempts": attempts,
152
- "reason": attempts[-1]["reason"], "next_executor": "fresh_subagent",
153
- "partial_work_possible": mode == "task"}
154
- return result
147
+ return {"status": "fallback_required", "attempts": [{"requested_model": model, "reason": reason}],
148
+ "reason": reason, "next_executor": "fresh_subagent", "partial_work_possible": mode == "task"}
155
149
 
156
150
 
157
151
  def main(argv=None):
@@ -159,7 +153,7 @@ def main(argv=None):
159
153
  parser.add_argument("--prompt-file", required=True, type=Path)
160
154
  parser.add_argument("--mode", choices=("consult", "task"), default="consult")
161
155
  parser.add_argument("--cwd", type=Path)
162
- parser.add_argument("--start-model", choices=MODELS)
156
+ parser.add_argument("--start-model", help="仅任务模式:显式覆盖模型;省略时使用 Claude Code 配置的默认模型")
163
157
  parser.add_argument("--allow-command", action="append", default=[])
164
158
  parser.add_argument("--timeout", type=float, help="无活动告警阈值(秒),不终止或切换模型")
165
159
  args = parser.parse_args(argv)
@@ -171,6 +165,8 @@ def main(argv=None):
171
165
  parser.error("任务模式必须通过 --cwd 指定存在的工作目录")
172
166
  if args.mode == "consult" and (args.start_model or args.allow_command):
173
167
  parser.error("--start-model 和 --allow-command 仅用于任务模式")
168
+ if args.start_model is not None and not args.start_model.strip():
169
+ parser.error("--start-model 不能为空;使用默认模型时请省略此参数")
174
170
  try:
175
171
  prompt = args.prompt_file.read_text(encoding="utf-8")
176
172
  if not prompt.strip():
@@ -24,12 +24,13 @@ class AskCCTest(unittest.TestCase):
24
24
  def test_cli_entrypoints_do_not_switch_models_after_idle_threshold(self):
25
25
  skills = Path(__file__).resolve().parents[2]
26
26
  entries = [
27
- (skills / "ask_3th/scripts/ask_3th.py", "consult"),
28
- (skills / "delegate_3th/scripts/delegate_3th.py", "consult"),
29
- (skills / "delegate_3th/scripts/delegate_3th.py", "task"),
27
+ (skills / "ask_3th/scripts/ask_3th.py", "consult", None),
28
+ (skills / "delegate_3th/scripts/delegate_3th.py", "consult", None),
29
+ (skills / "delegate_3th/scripts/delegate_3th.py", "task", None),
30
+ (skills / "delegate_3th/scripts/delegate_3th.py", "task", "custom-model-alias"),
30
31
  ]
31
- for entry, mode in entries:
32
- with self.subTest(entry=entry, mode=mode), tempfile.TemporaryDirectory() as tmp:
32
+ for entry, mode, model in entries:
33
+ with self.subTest(entry=entry, mode=mode, model=model), tempfile.TemporaryDirectory() as tmp:
33
34
  root = Path(tmp)
34
35
  fake_cli = root / "claude"
35
36
  fake_cli.write_text(
@@ -38,7 +39,7 @@ class AskCCTest(unittest.TestCase):
38
39
  "from pathlib import Path\n"
39
40
  "sys.stdin.read()\n"
40
41
  "with Path(os.environ['DELEGATE_TEST_CALLS']).open('a') as log:\n"
41
- " log.write(sys.argv[sys.argv.index('--model') + 1] + '\\n')\n"
42
+ " log.write(json.dumps({'args': sys.argv[1:], 'configured': os.environ.get('ANTHROPIC_MODEL')}) + '\\n')\n"
42
43
  "time.sleep(0.2)\n"
43
44
  "print(json.dumps({'type': 'result', 'subtype': 'success', 'result': 'done'}))\n"
44
45
  )
@@ -47,16 +48,29 @@ class AskCCTest(unittest.TestCase):
47
48
  prompt.write_text("test")
48
49
  calls = root / "calls.txt"
49
50
  env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ.get("PATH", ""),
50
- DELEGATE_TEST_CALLS=str(calls))
51
+ DELEGATE_TEST_CALLS=str(calls), ANTHROPIC_MODEL="user-configured-model")
51
52
  command = [sys.executable, str(entry), "--prompt-file", str(prompt), "--timeout", "0.05"]
52
53
  if mode == "task":
53
54
  command += ["--mode", "task", "--cwd", tmp]
55
+ if model is not None:
56
+ command += ["--start-model", model]
54
57
  completed = subprocess.run(command, env=env, capture_output=True, text=True, timeout=10)
55
58
  self.assertEqual(completed.returncode, 0, completed.stderr)
56
59
  response = json.loads(completed.stdout)
57
60
  self.assertEqual(response["status"], "review_required" if mode == "task" else "ok")
58
61
  self.assertEqual(response["attempts"], [])
59
- self.assertEqual(calls.read_text().splitlines(), [ask_3th.MODELS[0]])
62
+ self.assertEqual(response["requested_model"], model)
63
+ invocations = [json.loads(line) for line in calls.read_text().splitlines()]
64
+ self.assertEqual(len(invocations), 1)
65
+ invocation = invocations[0]
66
+ self.assertEqual(invocation["configured"], "user-configured-model")
67
+ actual_args = invocation["args"]
68
+ if model is None:
69
+ self.assertNotIn("--model", actual_args)
70
+ else:
71
+ self.assertEqual(actual_args[actual_args.index("--model") + 1], model)
72
+ self.assertNotIn("--setting-sources", actual_args)
73
+ self.assertNotIn("model", json.loads(actual_args[actual_args.index("--settings") + 1]))
60
74
  self.assertIn("疑似停滞", completed.stderr)
61
75
 
62
76
  @patch.object(ask_3th.shutil, "which", return_value="/fake/claude")
@@ -65,6 +79,8 @@ class AskCCTest(unittest.TestCase):
65
79
  response = ask_3th.ask("修改代码", mode="task", cwd=cwd, allow_commands=["python3 -m unittest"])
66
80
  command = run.call_args.args[0]
67
81
  self.assertEqual(response["status"], "review_required")
82
+ self.assertIsNone(response["requested_model"])
83
+ self.assertNotIn("--model", command)
68
84
  self.assertEqual(command[command.index("--permission-mode") + 1], "acceptEdits")
69
85
  self.assertIn("Bash(python3 -m unittest)", command)
70
86
  self.assertNotIn("bypassPermissions", command)
@@ -87,32 +103,42 @@ class AskCCTest(unittest.TestCase):
87
103
  @patch.object(ask_3th.shutil, "which", return_value="/fake/claude")
88
104
  def test_explicit_model_failure_requests_fresh_subagent(self, _which):
89
105
  with tempfile.TemporaryDirectory() as cwd, patch.object(ask_3th, "run_task", return_value=result("usage limit", True)):
90
- response = ask_3th.ask("接续", mode="task", cwd=cwd, start_model="claude-opus-5")
106
+ response = ask_3th.ask("接续", mode="task", cwd=cwd, start_model="custom-model-alias")
91
107
  self.assertEqual(response["status"], "fallback_required")
92
108
  self.assertEqual(response["next_executor"], "fresh_subagent")
93
109
  self.assertNotIn("next_model", response)
110
+ self.assertEqual(response["attempts"][0]["requested_model"], "custom-model-alias")
94
111
 
95
112
  def test_task_requires_explicit_directory(self):
96
113
  with self.assertRaises(ValueError):
97
114
  ask_3th.ask("修改代码", mode="task")
98
115
 
116
+ def test_empty_model_is_rejected_instead_of_overriding_cli_default(self):
117
+ with tempfile.TemporaryDirectory() as cwd:
118
+ for model in ["", " "]:
119
+ with self.subTest(model=model), self.assertRaises(ValueError):
120
+ ask_3th.ask("修改代码", mode="task", cwd=cwd, start_model=model)
121
+ completed = subprocess.run([
122
+ sys.executable, str(Path(ask_3th.__file__)), "--mode", "task", "--cwd", cwd,
123
+ "--prompt-file", str(Path(cwd, "unused.txt")), "--start-model=",
124
+ ], capture_output=True, text=True, timeout=10)
125
+ self.assertEqual(completed.returncode, 2)
126
+ self.assertIn("--start-model 不能为空", completed.stderr)
127
+
99
128
  @patch.object(ask_3th.shutil, "which", return_value="/fake/claude")
100
129
  def test_first_failure_requests_fallback_without_second_model(self, _which):
101
130
  seen = []
102
131
 
103
132
  def cli(command, *args):
104
- model = command[command.index("--model") + 1]
105
- seen.append(model)
106
- if model == "claude-fable-5-1":
107
- return result("usage limit exceeded", True)
108
- if model == "claude-opus-5":
109
- return result("OK")
110
- return result(f"There's an issue with the selected model ({model}).", True)
133
+ seen.append(command)
134
+ return result("usage limit exceeded", True)
111
135
 
112
136
  with patch.object(ask_3th, "run_task", side_effect=cli):
113
137
  response = ask_3th.ask("仅回答 OK")
114
138
  self.assertEqual(response["status"], "fallback_required")
115
- self.assertEqual(seen, ["claude-fable-5-1"])
139
+ self.assertEqual(len(seen), 1)
140
+ self.assertNotIn("--model", seen[0])
141
+ self.assertIsNone(response["attempts"][0]["requested_model"])
116
142
  self.assertEqual(response["next_executor"], "fresh_subagent")
117
143
  self.assertEqual(response["reason"], "额度不足或限流")
118
144