@yivan-lab/pretty-please 1.0.0 → 1.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 (89) hide show
  1. package/README.md +381 -28
  2. package/bin/pls.tsx +1138 -109
  3. package/dist/bin/pls.d.ts +1 -1
  4. package/dist/bin/pls.js +994 -91
  5. package/dist/package.json +80 -0
  6. package/dist/src/ai.d.ts +1 -41
  7. package/dist/src/ai.js +9 -190
  8. package/dist/src/alias.d.ts +41 -0
  9. package/dist/src/alias.js +240 -0
  10. package/dist/src/builtin-detector.d.ts +14 -8
  11. package/dist/src/builtin-detector.js +36 -16
  12. package/dist/src/chat-history.d.ts +16 -11
  13. package/dist/src/chat-history.js +35 -4
  14. package/dist/src/components/Chat.js +5 -4
  15. package/dist/src/components/CodeColorizer.js +26 -20
  16. package/dist/src/components/CommandBox.js +3 -17
  17. package/dist/src/components/ConfirmationPrompt.d.ts +2 -1
  18. package/dist/src/components/ConfirmationPrompt.js +9 -4
  19. package/dist/src/components/Duration.js +2 -1
  20. package/dist/src/components/InlineRenderer.js +2 -1
  21. package/dist/src/components/MarkdownDisplay.js +2 -1
  22. package/dist/src/components/MultiStepCommandGenerator.d.ts +5 -1
  23. package/dist/src/components/MultiStepCommandGenerator.js +127 -14
  24. package/dist/src/components/TableRenderer.js +2 -1
  25. package/dist/src/config.d.ts +59 -9
  26. package/dist/src/config.js +147 -48
  27. package/dist/src/history.d.ts +19 -5
  28. package/dist/src/history.js +26 -11
  29. package/dist/src/mastra-agent.d.ts +0 -1
  30. package/dist/src/mastra-agent.js +3 -4
  31. package/dist/src/mastra-chat.d.ts +28 -0
  32. package/dist/src/mastra-chat.js +93 -0
  33. package/dist/src/multi-step.d.ts +23 -7
  34. package/dist/src/multi-step.js +29 -6
  35. package/dist/src/prompts.d.ts +11 -0
  36. package/dist/src/prompts.js +140 -0
  37. package/dist/src/remote-history.d.ts +63 -0
  38. package/dist/src/remote-history.js +315 -0
  39. package/dist/src/remote.d.ts +113 -0
  40. package/dist/src/remote.js +634 -0
  41. package/dist/src/shell-hook.d.ts +87 -12
  42. package/dist/src/shell-hook.js +315 -17
  43. package/dist/src/sysinfo.d.ts +9 -5
  44. package/dist/src/sysinfo.js +2 -2
  45. package/dist/src/ui/theme.d.ts +27 -24
  46. package/dist/src/ui/theme.js +71 -21
  47. package/dist/src/upgrade.d.ts +41 -0
  48. package/dist/src/upgrade.js +348 -0
  49. package/dist/src/utils/console.d.ts +11 -11
  50. package/dist/src/utils/console.js +26 -17
  51. package/package.json +11 -9
  52. package/src/alias.ts +301 -0
  53. package/src/builtin-detector.ts +126 -0
  54. package/src/chat-history.ts +140 -0
  55. package/src/components/Chat.tsx +6 -5
  56. package/src/components/CodeColorizer.tsx +27 -19
  57. package/src/components/CommandBox.tsx +3 -17
  58. package/src/components/ConfirmationPrompt.tsx +11 -3
  59. package/src/components/Duration.tsx +2 -1
  60. package/src/components/InlineRenderer.tsx +2 -1
  61. package/src/components/MarkdownDisplay.tsx +2 -1
  62. package/src/components/MultiStepCommandGenerator.tsx +167 -16
  63. package/src/components/TableRenderer.tsx +2 -1
  64. package/src/config.ts +394 -0
  65. package/src/history.ts +160 -0
  66. package/src/mastra-agent.ts +3 -4
  67. package/src/mastra-chat.ts +124 -0
  68. package/src/multi-step.ts +45 -8
  69. package/src/prompts.ts +154 -0
  70. package/src/remote-history.ts +390 -0
  71. package/src/remote.ts +800 -0
  72. package/src/shell-hook.ts +754 -0
  73. package/src/{sysinfo.js → sysinfo.ts} +28 -16
  74. package/src/ui/theme.ts +101 -24
  75. package/src/upgrade.ts +397 -0
  76. package/src/utils/{console.js → console.ts} +36 -27
  77. package/bin/pls.js +0 -681
  78. package/src/ai.js +0 -324
  79. package/src/builtin-detector.js +0 -98
  80. package/src/chat-history.js +0 -94
  81. package/src/components/ChatStatus.tsx +0 -53
  82. package/src/components/CommandGenerator.tsx +0 -184
  83. package/src/components/ConfigDisplay.tsx +0 -64
  84. package/src/components/ConfigWizard.tsx +0 -101
  85. package/src/components/HistoryDisplay.tsx +0 -69
  86. package/src/components/HookManager.tsx +0 -150
  87. package/src/config.js +0 -221
  88. package/src/history.js +0 -131
  89. package/src/shell-hook.js +0 -393
@@ -0,0 +1,754 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import os from 'os'
4
+ import chalk from 'chalk'
5
+ import { CONFIG_DIR, getConfig, setConfigValue } from './config.js'
6
+ import { getHistory } from './history.js'
7
+ import { getCurrentTheme } from './ui/theme.js'
8
+
9
+ // 获取主题颜色
10
+ function getColors() {
11
+ const theme = getCurrentTheme()
12
+ return {
13
+ primary: theme.primary,
14
+ success: theme.success,
15
+ error: theme.error,
16
+ warning: theme.warning,
17
+ secondary: theme.secondary,
18
+ }
19
+ }
20
+
21
+ const SHELL_HISTORY_FILE = path.join(CONFIG_DIR, 'shell_history.jsonl')
22
+ const MAX_SHELL_HISTORY = 20
23
+
24
+ // Hook 标记,用于识别我们添加的内容
25
+ const HOOK_START_MARKER = '# >>> pretty-please shell hook >>>'
26
+ const HOOK_END_MARKER = '# <<< pretty-please shell hook <<<'
27
+
28
+ // Shell 类型
29
+ type ShellType = 'zsh' | 'bash' | 'powershell' | 'unknown'
30
+
31
+ /**
32
+ * Shell 历史记录项
33
+ */
34
+ export interface ShellHistoryItem {
35
+ cmd: string
36
+ exit: number
37
+ time: string
38
+ }
39
+
40
+ /**
41
+ * Hook 状态
42
+ */
43
+ export interface HookStatus {
44
+ enabled: boolean
45
+ installed: boolean
46
+ shellType: ShellType
47
+ configPath: string | null
48
+ historyFile: string
49
+ }
50
+
51
+ /**
52
+ * 检测当前 shell 类型
53
+ */
54
+ export function detectShell(): ShellType {
55
+ const shell = process.env.SHELL || ''
56
+ if (shell.includes('zsh')) return 'zsh'
57
+ if (shell.includes('bash')) return 'bash'
58
+ // Windows PowerShell
59
+ if (process.platform === 'win32') return 'powershell'
60
+ return 'unknown'
61
+ }
62
+
63
+ /**
64
+ * 获取 shell 配置文件路径
65
+ */
66
+ export function getShellConfigPath(shellType: ShellType): string | null {
67
+ const home = os.homedir()
68
+ switch (shellType) {
69
+ case 'zsh':
70
+ return path.join(home, '.zshrc')
71
+ case 'bash':
72
+ // macOS 使用 .bash_profile,Linux 使用 .bashrc
73
+ if (process.platform === 'darwin') {
74
+ return path.join(home, '.bash_profile')
75
+ }
76
+ return path.join(home, '.bashrc')
77
+ case 'powershell':
78
+ // PowerShell profile 路径
79
+ return path.join(home, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1')
80
+ default:
81
+ return null
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 生成 zsh hook 脚本
87
+ */
88
+ function generateZshHook(): string {
89
+ return `
90
+ ${HOOK_START_MARKER}
91
+ # 记录命令到 pretty-please 历史
92
+ __pls_preexec() {
93
+ __PLS_LAST_CMD="$1"
94
+ __PLS_CMD_START=$(date +%s)
95
+ }
96
+
97
+ __pls_precmd() {
98
+ local exit_code=$?
99
+ if [[ -n "$__PLS_LAST_CMD" ]]; then
100
+ local end_time=$(date +%s)
101
+ local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
102
+ # 转义命令中的特殊字符
103
+ local escaped_cmd=$(echo "$__PLS_LAST_CMD" | sed 's/\\\\/\\\\\\\\/g; s/"/\\\\"/g')
104
+ echo "{\\"cmd\\":\\"$escaped_cmd\\",\\"exit\\":$exit_code,\\"time\\":\\"$timestamp\\"}" >> "${CONFIG_DIR}/shell_history.jsonl"
105
+ # 保持文件不超过 ${MAX_SHELL_HISTORY} 行
106
+ tail -n ${MAX_SHELL_HISTORY} "${CONFIG_DIR}/shell_history.jsonl" > "${CONFIG_DIR}/shell_history.jsonl.tmp" && mv "${CONFIG_DIR}/shell_history.jsonl.tmp" "${CONFIG_DIR}/shell_history.jsonl"
107
+ unset __PLS_LAST_CMD
108
+ fi
109
+ }
110
+
111
+ autoload -Uz add-zsh-hook
112
+ add-zsh-hook preexec __pls_preexec
113
+ add-zsh-hook precmd __pls_precmd
114
+ ${HOOK_END_MARKER}
115
+ `
116
+ }
117
+
118
+ /**
119
+ * 生成 bash hook 脚本
120
+ */
121
+ function generateBashHook(): string {
122
+ return `
123
+ ${HOOK_START_MARKER}
124
+ # 记录命令到 pretty-please 历史
125
+ __pls_prompt_command() {
126
+ local exit_code=$?
127
+ local last_cmd=$(history 1 | sed 's/^ *[0-9]* *//')
128
+ if [[ -n "$last_cmd" && "$last_cmd" != "$__PLS_LAST_CMD" ]]; then
129
+ __PLS_LAST_CMD="$last_cmd"
130
+ local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
131
+ local escaped_cmd=$(echo "$last_cmd" | sed 's/\\\\/\\\\\\\\/g; s/"/\\\\"/g')
132
+ echo "{\\"cmd\\":\\"$escaped_cmd\\",\\"exit\\":$exit_code,\\"time\\":\\"$timestamp\\"}" >> "${CONFIG_DIR}/shell_history.jsonl"
133
+ tail -n ${MAX_SHELL_HISTORY} "${CONFIG_DIR}/shell_history.jsonl" > "${CONFIG_DIR}/shell_history.jsonl.tmp" && mv "${CONFIG_DIR}/shell_history.jsonl.tmp" "${CONFIG_DIR}/shell_history.jsonl"
134
+ fi
135
+ }
136
+
137
+ if [[ ! "$PROMPT_COMMAND" =~ __pls_prompt_command ]]; then
138
+ PROMPT_COMMAND="__pls_prompt_command;\${PROMPT_COMMAND}"
139
+ fi
140
+ ${HOOK_END_MARKER}
141
+ `
142
+ }
143
+
144
+ /**
145
+ * 生成 PowerShell hook 脚本
146
+ */
147
+ function generatePowerShellHook(): string {
148
+ return `
149
+ ${HOOK_START_MARKER}
150
+ # 记录命令到 pretty-please 历史
151
+ $Global:__PlsLastCmd = ""
152
+
153
+ function __Pls_RecordCommand {
154
+ $lastCmd = (Get-History -Count 1).CommandLine
155
+ if ($lastCmd -and $lastCmd -ne $Global:__PlsLastCmd) {
156
+ $Global:__PlsLastCmd = $lastCmd
157
+ $exitCode = $LASTEXITCODE
158
+ if ($null -eq $exitCode) { $exitCode = 0 }
159
+ $timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
160
+ $escapedCmd = $lastCmd -replace '\\\\', '\\\\\\\\' -replace '"', '\\\\"'
161
+ $json = "{\`"cmd\`":\`"$escapedCmd\`",\`"exit\`":$exitCode,\`"time\`":\`"$timestamp\`"}"
162
+ Add-Content -Path "${CONFIG_DIR}/shell_history.jsonl" -Value $json
163
+ # 保持文件不超过 ${MAX_SHELL_HISTORY} 行
164
+ $content = Get-Content "${CONFIG_DIR}/shell_history.jsonl" -Tail ${MAX_SHELL_HISTORY}
165
+ $content | Set-Content "${CONFIG_DIR}/shell_history.jsonl"
166
+ }
167
+ }
168
+
169
+ if (-not (Get-Variable -Name __PlsPromptBackup -ErrorAction SilentlyContinue)) {
170
+ $Global:__PlsPromptBackup = $function:prompt
171
+ function Global:prompt {
172
+ __Pls_RecordCommand
173
+ & $Global:__PlsPromptBackup
174
+ }
175
+ }
176
+ ${HOOK_END_MARKER}
177
+ `
178
+ }
179
+
180
+ /**
181
+ * 生成 hook 脚本
182
+ */
183
+ function generateHookScript(shellType: ShellType): string | null {
184
+ switch (shellType) {
185
+ case 'zsh':
186
+ return generateZshHook()
187
+ case 'bash':
188
+ return generateBashHook()
189
+ case 'powershell':
190
+ return generatePowerShellHook()
191
+ default:
192
+ return null
193
+ }
194
+ }
195
+
196
+ /**
197
+ * 安装 shell hook
198
+ */
199
+ export async function installShellHook(): Promise<boolean> {
200
+ const shellType = detectShell()
201
+ const configPath = getShellConfigPath(shellType)
202
+ const colors = getColors()
203
+
204
+ if (!configPath) {
205
+ console.log(chalk.hex(colors.error)(`❌ 不支持的 shell 类型: ${shellType}`))
206
+ return false
207
+ }
208
+
209
+ const hookScript = generateHookScript(shellType)
210
+ if (!hookScript) {
211
+ console.log(chalk.hex(colors.error)(`❌ 无法为 ${shellType} 生成 hook 脚本`))
212
+ return false
213
+ }
214
+
215
+ // 检查是否已安装
216
+ if (fs.existsSync(configPath)) {
217
+ const content = fs.readFileSync(configPath, 'utf-8')
218
+ if (content.includes(HOOK_START_MARKER)) {
219
+ console.log(chalk.hex(colors.warning)('⚠️ Shell hook 已安装,跳过'))
220
+ setConfigValue('shellHook', true)
221
+ return true
222
+ }
223
+ }
224
+
225
+ // 确保配置目录存在
226
+ if (!fs.existsSync(CONFIG_DIR)) {
227
+ fs.mkdirSync(CONFIG_DIR, { recursive: true })
228
+ }
229
+
230
+ // 备份原配置文件
231
+ if (fs.existsSync(configPath)) {
232
+ const backupPath = configPath + '.pls-backup'
233
+ fs.copyFileSync(configPath, backupPath)
234
+ console.log(chalk.gray(`已备份原配置文件到: ${backupPath}`))
235
+ }
236
+
237
+ // 追加 hook 脚本
238
+ fs.appendFileSync(configPath, hookScript)
239
+
240
+ // 更新配置
241
+ setConfigValue('shellHook', true)
242
+
243
+ console.log(chalk.hex(colors.success)(`✅ Shell hook 已安装到: ${configPath}`))
244
+ console.log(chalk.hex(colors.warning)('⚠️ 请重启终端或执行以下命令使其生效:'))
245
+ console.log(chalk.hex(colors.primary)(` source ${configPath}`))
246
+
247
+ return true
248
+ }
249
+
250
+ /**
251
+ * 卸载 shell hook
252
+ */
253
+ export function uninstallShellHook(): boolean {
254
+ const shellType = detectShell()
255
+ const configPath = getShellConfigPath(shellType)
256
+ const colors = getColors()
257
+
258
+ if (!configPath || !fs.existsSync(configPath)) {
259
+ console.log(chalk.hex(colors.warning)('⚠️ 未找到 shell 配置文件'))
260
+ setConfigValue('shellHook', false)
261
+ return true
262
+ }
263
+
264
+ let content = fs.readFileSync(configPath, 'utf-8')
265
+
266
+ // 移除 hook 脚本
267
+ const startIndex = content.indexOf(HOOK_START_MARKER)
268
+ const endIndex = content.indexOf(HOOK_END_MARKER)
269
+
270
+ if (startIndex === -1 || endIndex === -1) {
271
+ console.log(chalk.hex(colors.warning)('⚠️ 未找到已安装的 hook'))
272
+ setConfigValue('shellHook', false)
273
+ return true
274
+ }
275
+
276
+ // 移除从标记开始到结束的所有内容(包括换行符)
277
+ const before = content.substring(0, startIndex)
278
+ const after = content.substring(endIndex + HOOK_END_MARKER.length)
279
+ content = before + after.replace(/^\n/, '')
280
+
281
+ fs.writeFileSync(configPath, content)
282
+ setConfigValue('shellHook', false)
283
+
284
+ // 清空 shell 历史文件
285
+ if (fs.existsSync(SHELL_HISTORY_FILE)) {
286
+ fs.unlinkSync(SHELL_HISTORY_FILE)
287
+ }
288
+
289
+ console.log(chalk.hex(colors.success)('✅ Shell hook 已卸载'))
290
+ console.log(chalk.hex(colors.warning)('⚠️ 请重启终端使其生效'))
291
+
292
+ return true
293
+ }
294
+
295
+ /**
296
+ * 读取 shell 历史记录
297
+ */
298
+ export function getShellHistory(): ShellHistoryItem[] {
299
+ const config = getConfig()
300
+
301
+ // 如果未启用 shell hook,返回空数组
302
+ if (!config.shellHook) {
303
+ return []
304
+ }
305
+
306
+ if (!fs.existsSync(SHELL_HISTORY_FILE)) {
307
+ return []
308
+ }
309
+
310
+ try {
311
+ const content = fs.readFileSync(SHELL_HISTORY_FILE, 'utf-8')
312
+ const lines = content
313
+ .trim()
314
+ .split('\n')
315
+ .filter((line) => line.trim())
316
+
317
+ return lines
318
+ .map((line) => {
319
+ try {
320
+ return JSON.parse(line) as ShellHistoryItem
321
+ } catch {
322
+ return null
323
+ }
324
+ })
325
+ .filter((item): item is ShellHistoryItem => item !== null)
326
+ } catch {
327
+ return []
328
+ }
329
+ }
330
+
331
+ /**
332
+ * 从 pls history 中查找匹配的记录
333
+ */
334
+ function findPlsHistoryMatch(prompt: string): ReturnType<typeof getHistory>[number] | null {
335
+ const plsHistory = getHistory()
336
+
337
+ // 尝试精确匹配 userPrompt
338
+ for (const record of plsHistory) {
339
+ if (record.userPrompt === prompt) {
340
+ return record
341
+ }
342
+ }
343
+
344
+ // 尝试模糊匹配(处理引号等情况)
345
+ const normalizedPrompt = prompt.trim().replace(/^["']|["']$/g, '')
346
+ for (const record of plsHistory) {
347
+ if (record.userPrompt === normalizedPrompt) {
348
+ return record
349
+ }
350
+ }
351
+
352
+ return null
353
+ }
354
+
355
+ /**
356
+ * 格式化 shell 历史供 AI 使用
357
+ * 对于 pls 命令,会从 pls history 中查找对应的详细信息
358
+ */
359
+ export function formatShellHistoryForAI(): string {
360
+ const history = getShellHistory()
361
+
362
+ if (history.length === 0) {
363
+ return ''
364
+ }
365
+
366
+ // pls 的子命令列表(这些不是 AI prompt)
367
+ const plsSubcommands = ['config', 'history', 'hook', 'help', '--help', '-h', '--version', '-v']
368
+
369
+ const lines = history.map((item, index) => {
370
+ const status = item.exit === 0 ? '✓' : `✗ 退出码:${item.exit}`
371
+
372
+ // 检查是否是 pls 命令
373
+ const plsMatch = item.cmd.match(/^(pls|please)\s+(.+)$/)
374
+ if (plsMatch) {
375
+ let args = plsMatch[2]
376
+
377
+ // 去掉 --debug / -d 选项,获取真正的参数
378
+ args = args.replace(/^(--debug|-d)\s+/, '')
379
+
380
+ const firstArg = args.split(/\s+/)[0]
381
+
382
+ // 如果是子命令,当作普通命令处理
383
+ if (plsSubcommands.includes(firstArg)) {
384
+ return `${index + 1}. ${item.cmd} ${status}`
385
+ }
386
+
387
+ // 是 AI prompt,尝试从 pls history 查找详细信息
388
+ const prompt = args
389
+ const plsRecord = findPlsHistoryMatch(prompt)
390
+
391
+ if (plsRecord) {
392
+ // 找到对应的 pls 记录,展示详细信息
393
+ if (plsRecord.reason === 'builtin') {
394
+ return `${index + 1}. [pls] "${prompt}" → 生成命令: ${plsRecord.command} (包含 builtin,未执行)`
395
+ } else if (plsRecord.executed) {
396
+ const execStatus = plsRecord.exitCode === 0 ? '✓' : `✗ 退出码:${plsRecord.exitCode}`
397
+
398
+ // 检查用户是否修改了命令
399
+ if (plsRecord.userModified && plsRecord.aiGeneratedCommand) {
400
+ return `${index + 1}. [pls] "${prompt}" → AI 生成: ${plsRecord.aiGeneratedCommand} / 用户修改为: ${plsRecord.command} ${execStatus}`
401
+ } else {
402
+ return `${index + 1}. [pls] "${prompt}" → 实际执行: ${plsRecord.command} ${execStatus}`
403
+ }
404
+ } else {
405
+ return `${index + 1}. [pls] "${prompt}" → 生成命令: ${plsRecord.command} (用户取消执行)`
406
+ }
407
+ }
408
+ // 找不到记录,只显示原始命令
409
+ return `${index + 1}. [pls] "${prompt}" ${status}`
410
+ }
411
+
412
+ // 普通命令
413
+ return `${index + 1}. ${item.cmd} ${status}`
414
+ })
415
+
416
+ return `【用户终端最近执行的命令】\n${lines.join('\n')}`
417
+ }
418
+
419
+ /**
420
+ * 获取 hook 状态
421
+ */
422
+ export function getHookStatus(): HookStatus {
423
+ const config = getConfig()
424
+ const shellType = detectShell()
425
+ const configPath = getShellConfigPath(shellType)
426
+
427
+ let installed = false
428
+ if (configPath && fs.existsSync(configPath)) {
429
+ const content = fs.readFileSync(configPath, 'utf-8')
430
+ installed = content.includes(HOOK_START_MARKER)
431
+ }
432
+
433
+ return {
434
+ enabled: config.shellHook,
435
+ installed,
436
+ shellType,
437
+ configPath,
438
+ historyFile: SHELL_HISTORY_FILE,
439
+ }
440
+ }
441
+
442
+ /**
443
+ * 显示 shell 历史
444
+ */
445
+ export function displayShellHistory(): void {
446
+ const config = getConfig()
447
+ const history = getShellHistory()
448
+ const colors = getColors()
449
+
450
+ if (!config.shellHook) {
451
+ console.log('')
452
+ console.log(chalk.hex(colors.warning)('⚠️ Shell Hook 未启用'))
453
+ console.log(chalk.gray('运行 ') + chalk.hex(colors.primary)('pls hook install') + chalk.gray(' 启用 Shell Hook'))
454
+ console.log('')
455
+ return
456
+ }
457
+
458
+ if (history.length === 0) {
459
+ console.log('')
460
+ console.log(chalk.gray('暂无 Shell 历史记录'))
461
+ console.log('')
462
+ return
463
+ }
464
+
465
+ console.log('')
466
+ console.log(chalk.bold(`终端历史(最近 ${history.length} 条):`))
467
+ console.log(chalk.gray('━'.repeat(50)))
468
+
469
+ history.forEach((item, index) => {
470
+ const num = index + 1
471
+ const status = item.exit === 0 ? chalk.hex(colors.success)('✓') : chalk.hex(colors.error)(`✗ (${item.exit})`)
472
+
473
+ // 检查是否是 pls 命令
474
+ const isPls = item.cmd.startsWith('pls ') || item.cmd.startsWith('please ')
475
+
476
+ if (isPls) {
477
+ // pls 命令:尝试从 history 查找详细信息
478
+ const args = item.cmd.replace(/^(pls|please)\s+/, '')
479
+ const plsRecord = findPlsHistoryMatch(args)
480
+
481
+ if (plsRecord && plsRecord.executed) {
482
+ // 检查用户是否修改了命令
483
+ if (plsRecord.userModified && plsRecord.aiGeneratedCommand) {
484
+ console.log(` ${chalk.hex(colors.primary)(num.toString().padStart(2, ' '))}. ${chalk.hex(colors.secondary)('[pls]')} "${args}"`)
485
+ console.log(` ${chalk.dim('AI 生成:')} ${chalk.gray(plsRecord.aiGeneratedCommand)}`)
486
+ console.log(
487
+ ` ${chalk.dim('用户修改为:')} ${plsRecord.command} ${status} ${chalk.hex(colors.warning)('(已修改)')}`
488
+ )
489
+ } else {
490
+ console.log(
491
+ ` ${chalk.hex(colors.primary)(num.toString().padStart(2, ' '))}. ${chalk.hex(colors.secondary)('[pls]')} "${args}" → ${plsRecord.command} ${status}`
492
+ )
493
+ }
494
+ } else {
495
+ console.log(` ${chalk.hex(colors.primary)(num.toString().padStart(2, ' '))}. ${chalk.hex(colors.secondary)('[pls]')} ${args} ${status}`)
496
+ }
497
+ } else {
498
+ console.log(` ${chalk.hex(colors.primary)(num.toString().padStart(2, ' '))}. ${item.cmd} ${status}`)
499
+ }
500
+ })
501
+
502
+ console.log(chalk.gray('━'.repeat(50)))
503
+ console.log(chalk.gray(`配置: 保留最近 ${config.shellHistoryLimit} 条`))
504
+ console.log(chalk.gray(`文件: ${SHELL_HISTORY_FILE}`))
505
+ console.log('')
506
+ }
507
+
508
+ /**
509
+ * 清空 shell 历史
510
+ */
511
+ export function clearShellHistory(): void {
512
+ if (fs.existsSync(SHELL_HISTORY_FILE)) {
513
+ fs.unlinkSync(SHELL_HISTORY_FILE)
514
+ }
515
+ const colors = getColors()
516
+ console.log('')
517
+ console.log(chalk.hex(colors.success)('✓ Shell 历史已清空'))
518
+ console.log('')
519
+ }
520
+
521
+ // ================== 远程 Shell Hook ==================
522
+
523
+ /**
524
+ * 生成远程 zsh hook 脚本
525
+ */
526
+ function generateRemoteZshHook(): string {
527
+ return `
528
+ ${HOOK_START_MARKER}
529
+ # 记录命令到 pretty-please 历史
530
+ __pls_preexec() {
531
+ __PLS_LAST_CMD="$1"
532
+ __PLS_CMD_START=$(date +%s)
533
+ }
534
+
535
+ __pls_precmd() {
536
+ local exit_code=$?
537
+ if [[ -n "$__PLS_LAST_CMD" ]]; then
538
+ local end_time=$(date +%s)
539
+ local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
540
+ # 确保目录存在
541
+ mkdir -p ~/.please
542
+ # 转义命令中的特殊字符
543
+ local escaped_cmd=$(echo "$__PLS_LAST_CMD" | sed 's/\\\\/\\\\\\\\/g; s/"/\\\\"/g')
544
+ echo "{\\"cmd\\":\\"$escaped_cmd\\",\\"exit\\":$exit_code,\\"time\\":\\"$timestamp\\"}" >> ~/.please/shell_history.jsonl
545
+ # 保持文件不超过 50 行
546
+ tail -n 50 ~/.please/shell_history.jsonl > ~/.please/shell_history.jsonl.tmp && mv ~/.please/shell_history.jsonl.tmp ~/.please/shell_history.jsonl
547
+ unset __PLS_LAST_CMD
548
+ fi
549
+ }
550
+
551
+ autoload -Uz add-zsh-hook
552
+ add-zsh-hook preexec __pls_preexec
553
+ add-zsh-hook precmd __pls_precmd
554
+ ${HOOK_END_MARKER}
555
+ `
556
+ }
557
+
558
+ /**
559
+ * 生成远程 bash hook 脚本
560
+ */
561
+ function generateRemoteBashHook(): string {
562
+ return `
563
+ ${HOOK_START_MARKER}
564
+ # 记录命令到 pretty-please 历史
565
+ __pls_prompt_command() {
566
+ local exit_code=$?
567
+ local last_cmd=$(history 1 | sed 's/^ *[0-9]* *//')
568
+ if [[ -n "$last_cmd" && "$last_cmd" != "$__PLS_LAST_CMD" ]]; then
569
+ __PLS_LAST_CMD="$last_cmd"
570
+ local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
571
+ # 确保目录存在
572
+ mkdir -p ~/.please
573
+ local escaped_cmd=$(echo "$last_cmd" | sed 's/\\\\/\\\\\\\\/g; s/"/\\\\"/g')
574
+ echo "{\\"cmd\\":\\"$escaped_cmd\\",\\"exit\\":$exit_code,\\"time\\":\\"$timestamp\\"}" >> ~/.please/shell_history.jsonl
575
+ tail -n 50 ~/.please/shell_history.jsonl > ~/.please/shell_history.jsonl.tmp && mv ~/.please/shell_history.jsonl.tmp ~/.please/shell_history.jsonl
576
+ fi
577
+ }
578
+
579
+ if [[ ! "$PROMPT_COMMAND" =~ __pls_prompt_command ]]; then
580
+ PROMPT_COMMAND="__pls_prompt_command;\${PROMPT_COMMAND}"
581
+ fi
582
+ ${HOOK_END_MARKER}
583
+ `
584
+ }
585
+
586
+ /**
587
+ * 检测远程服务器的 shell 类型
588
+ */
589
+ export async function detectRemoteShell(sshExecFn: (cmd: string) => Promise<{ stdout: string; exitCode: number }>): Promise<ShellType> {
590
+ try {
591
+ const result = await sshExecFn('basename "$SHELL"')
592
+ if (result.exitCode === 0) {
593
+ const shell = result.stdout.trim()
594
+ if (shell === 'zsh') return 'zsh'
595
+ if (shell === 'bash') return 'bash'
596
+ }
597
+ } catch {
598
+ // 忽略错误
599
+ }
600
+ return 'bash' // 默认 bash
601
+ }
602
+
603
+ /**
604
+ * 获取远程 shell 配置文件路径
605
+ */
606
+ export function getRemoteShellConfigPath(shellType: ShellType): string {
607
+ switch (shellType) {
608
+ case 'zsh':
609
+ return '~/.zshrc'
610
+ case 'bash':
611
+ return '~/.bashrc'
612
+ default:
613
+ return '~/.bashrc'
614
+ }
615
+ }
616
+
617
+ /**
618
+ * 生成远程 hook 脚本
619
+ */
620
+ export function generateRemoteHookScript(shellType: ShellType): string | null {
621
+ switch (shellType) {
622
+ case 'zsh':
623
+ return generateRemoteZshHook()
624
+ case 'bash':
625
+ return generateRemoteBashHook()
626
+ default:
627
+ return null
628
+ }
629
+ }
630
+
631
+ /**
632
+ * 检查远程 hook 是否已安装
633
+ */
634
+ export async function checkRemoteHookInstalled(
635
+ sshExecFn: (cmd: string) => Promise<{ stdout: string; exitCode: number }>,
636
+ configPath: string
637
+ ): Promise<boolean> {
638
+ try {
639
+ const result = await sshExecFn(`grep -q "${HOOK_START_MARKER}" ${configPath} 2>/dev/null && echo "installed" || echo "not_installed"`)
640
+ return result.stdout.trim() === 'installed'
641
+ } catch {
642
+ return false
643
+ }
644
+ }
645
+
646
+ /**
647
+ * 在远程服务器安装 shell hook
648
+ */
649
+ export async function installRemoteShellHook(
650
+ sshExecFn: (cmd: string) => Promise<{ stdout: string; exitCode: number }>,
651
+ shellType: ShellType
652
+ ): Promise<{ success: boolean; message: string }> {
653
+ const colors = getColors()
654
+ const configPath = getRemoteShellConfigPath(shellType)
655
+ const hookScript = generateRemoteHookScript(shellType)
656
+
657
+ if (!hookScript) {
658
+ return { success: false, message: chalk.hex(colors.error)(`不支持的 shell 类型: ${shellType}`) }
659
+ }
660
+
661
+ // 检查是否已安装
662
+ const installed = await checkRemoteHookInstalled(sshExecFn, configPath)
663
+ if (installed) {
664
+ return { success: true, message: chalk.hex(colors.warning)('Shell hook 已安装,跳过') }
665
+ }
666
+
667
+ // 备份原配置文件
668
+ try {
669
+ await sshExecFn(`cp ${configPath} ${configPath}.pls-backup 2>/dev/null || true`)
670
+ } catch {
671
+ // 忽略备份错误
672
+ }
673
+
674
+ // 安装 hook
675
+ // 使用 cat 和 heredoc 来追加内容
676
+ const escapedScript = hookScript.replace(/'/g, "'\"'\"'")
677
+ const installCmd = `echo '${escapedScript}' >> ${configPath}`
678
+
679
+ try {
680
+ const result = await sshExecFn(installCmd)
681
+ if (result.exitCode !== 0) {
682
+ return { success: false, message: chalk.hex(colors.error)(`安装失败: ${result.stdout}`) }
683
+ }
684
+
685
+ // 确保 ~/.please 目录存在
686
+ await sshExecFn('mkdir -p ~/.please')
687
+
688
+ return {
689
+ success: true,
690
+ message: chalk.hex(colors.success)(`Shell hook 已安装到 ${configPath}`),
691
+ }
692
+ } catch (error) {
693
+ const message = error instanceof Error ? error.message : String(error)
694
+ return { success: false, message: chalk.hex(colors.error)(`安装失败: ${message}`) }
695
+ }
696
+ }
697
+
698
+ /**
699
+ * 从远程服务器卸载 shell hook
700
+ */
701
+ export async function uninstallRemoteShellHook(
702
+ sshExecFn: (cmd: string) => Promise<{ stdout: string; exitCode: number }>,
703
+ shellType: ShellType
704
+ ): Promise<{ success: boolean; message: string }> {
705
+ const colors = getColors()
706
+ const configPath = getRemoteShellConfigPath(shellType)
707
+
708
+ // 检查是否已安装
709
+ const installed = await checkRemoteHookInstalled(sshExecFn, configPath)
710
+ if (!installed) {
711
+ return { success: true, message: chalk.hex(colors.warning)('Shell hook 未安装,跳过') }
712
+ }
713
+
714
+ // 使用 sed 删除 hook 代码块
715
+ // 注意:需要处理特殊字符
716
+ const startMarkerEscaped = HOOK_START_MARKER.replace(/[[\]]/g, '\\$&')
717
+ const endMarkerEscaped = HOOK_END_MARKER.replace(/[[\]]/g, '\\$&')
718
+
719
+ // 在 macOS 和 Linux 上 sed -i 行为不同,使用 sed + 临时文件
720
+ const uninstallCmd = `
721
+ sed '/${startMarkerEscaped}/,/${endMarkerEscaped}/d' ${configPath} > ${configPath}.tmp && mv ${configPath}.tmp ${configPath}
722
+ `
723
+
724
+ try {
725
+ const result = await sshExecFn(uninstallCmd)
726
+ if (result.exitCode !== 0) {
727
+ return { success: false, message: chalk.hex(colors.error)(`卸载失败: ${result.stdout}`) }
728
+ }
729
+
730
+ return {
731
+ success: true,
732
+ message: chalk.hex(colors.success)('Shell hook 已卸载'),
733
+ }
734
+ } catch (error) {
735
+ const message = error instanceof Error ? error.message : String(error)
736
+ return { success: false, message: chalk.hex(colors.error)(`卸载失败: ${message}`) }
737
+ }
738
+ }
739
+
740
+ /**
741
+ * 获取远程 hook 状态
742
+ */
743
+ export async function getRemoteHookStatus(
744
+ sshExecFn: (cmd: string) => Promise<{ stdout: string; exitCode: number }>
745
+ ): Promise<{ installed: boolean; shellType: ShellType; configPath: string }> {
746
+ // 检测 shell 类型
747
+ const shellType = await detectRemoteShell(sshExecFn)
748
+ const configPath = getRemoteShellConfigPath(shellType)
749
+
750
+ // 检查是否已安装
751
+ const installed = await checkRemoteHookInstalled(sshExecFn, configPath)
752
+
753
+ return { installed, shellType, configPath }
754
+ }