@sglwsjxh/ffix 1.0.1

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.
@@ -0,0 +1,44 @@
1
+ name: Build and Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: write
10
+ packages: write
11
+ id-token: write
12
+
13
+ jobs:
14
+ publish:
15
+ name: Build and publish
16
+ runs-on: windows-latest
17
+
18
+ steps:
19
+ - name: Checkout repository
20
+ uses: actions/checkout@v4
21
+
22
+ - name: Setup Node.js for npm
23
+ uses: actions/setup-node@v4
24
+ with:
25
+ node-version: "22"
26
+ registry-url: "https://registry.npmjs.org/"
27
+
28
+ - name: Install dependencies
29
+ run: npm install --no-package-lock
30
+
31
+ - name: Build package
32
+ run: npm run build
33
+
34
+ - name: Create GitHub Release
35
+ uses: softprops/action-gh-release@v2
36
+ with:
37
+ generate_release_notes: true
38
+ env:
39
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
40
+
41
+ - name: Publish to npm
42
+ run: npm publish --access public
43
+ env:
44
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 清木殇
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # ffix ? fuck fix !!
2
+
3
+ 在 powershell 里敲错命令了? **fuck !!**
4
+
5
+ ```powershell
6
+ PS C:\Users\mark3> got branch
7
+ got: 我不认识这个命令
8
+ PS C:\Users\mark3> fuck
9
+ → 建议执行:git branch
10
+ ```
11
+
12
+ 按 Enter 就执行了,就是这么简单
13
+
14
+ ## 它干了什么
15
+
16
+ 你敲错命令 → fuck 拿到报错 → 丢给 AI → 返回修复命令 → 你确认后执行
17
+
18
+ ## 安装方法
19
+
20
+ ```powershell
21
+ npm i -g @sglwsjxh/ffix@latest
22
+ fuck install
23
+ pwsh
24
+ ```
25
+
26
+ ## 使用方法
27
+
28
+ ```powershell
29
+ # 敲错命令了
30
+ git brnch
31
+
32
+ # 让 AI 修一下
33
+ fuck
34
+ # → 建议执行:git branch
35
+ # 按 Enter 执行,Ctrl+C 取消
36
+ ```
37
+
38
+ ## 运行环境
39
+
40
+ - PowerShell 7
41
+ - Node.js 18+
42
+ - OpenAI 兼容的 api
43
+
44
+ ## 从源码开发
45
+
46
+ ```powershell
47
+ npm run build # 编译
48
+ npx tsx src/main.ts # 直接跑源码
49
+ ```
@@ -0,0 +1,5 @@
1
+ {
2
+ "baseUrl": "YOUR_BASE_URL",
3
+ "apiKey": "YOUR_API_KEY",
4
+ "model": "YOUR_MODEL"
5
+ }
package/dist/main.js ADDED
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ import{readFile as _,unlink as L}from"fs/promises";import{readFile as $,writeFile as I,mkdir as M}from"fs/promises";import{join as h}from"path";import{homedir as N}from"os";var P=h(N(),".ffix"),c=h(P,"config.json"),p={baseUrl:"",apiKey:"",model:""},R={stderrTailLines:20,timeoutMs:15e3,tempFilePath:"%TEMP%\\fuck_ctx.json"};async function w(){try{await $(c,"utf-8")}catch{await M(P,{recursive:!0}),await I(c,JSON.stringify(p,null,4),"utf-8"),console.log(`\u9996\u6B21\u4F7F\u7528\uFF0C\u8BF7\u914D\u7F6E API \u4FE1\u606F\uFF1A${c}`)}}async function y(){let t=await $(c,"utf-8"),e=JSON.parse(t);if(!e.apiKey)throw new Error(`\u8BF7\u5728 ${c} \u4E2D\u914D\u7F6E apiKey`);return{baseUrl:e.baseUrl||p.baseUrl,apiKey:e.apiKey,model:e.model||p.model}}async function m(){return R}function v(t){let e=process.env.TEMP??process.env.TMPDIR??"";return t.replace(/%TEMP%/g,e)}async function b(){let t=await m(),e=v(t.tempFilePath);return U(e)}async function U(t){try{let e=await _(t,"utf-8"),o=JSON.parse(e);return typeof o.lastCommand!="string"||o.lastCommand.length===0||typeof o.exitCode!="number"?null:o}catch{return null}finally{try{await L(t)}catch{}}}var D=`\u4F60\u662F\u4E00\u4E2A PowerShell 7 \u547D\u4EE4\u4FEE\u590D\u52A9\u624B\uFF0C\u5206\u6790\u5931\u8D25\u547D\u4EE4\u5E76\u7ED9\u51FA\u4FEE\u590D\u547D\u4EE4
3
+
4
+ \u4E25\u683C\u8981\u6C42\uFF1A
5
+ 1. \u53EA\u8FD4\u56DE JSON\uFF0C\u4E0D\u8981\u591A\u4F59\u6587\u5B57
6
+ 2. JSON \u683C\u5F0F\u4E25\u683C\u56FA\u5B9A\u4E3A {"command": "\u4FEE\u590D\u547D\u4EE4", "confidence": "high|medium|low"}
7
+ 3. command \u5FC5\u987B\u662F PowerShell 7 \u53EF\u76F4\u63A5\u6267\u884C\u7684\u4E00\u6761\u547D\u4EE4
8
+ 4. \u65E0\u6CD5\u4FEE\u590D\u65F6\u8FD4\u56DE {"command": "", "confidence": "low"}
9
+ 5. \u4E0D\u8981\u751F\u6210 bash/zsh \u547D\u4EE4
10
+ 6. \u4E0D\u8981\u5047\u8BBE\u7528\u6237\u60F3\u5B89\u88C5\u8F6F\u4EF6`;function G(t){return`\u4E0A\u4E00\u6761\u547D\u4EE4\u6267\u884C\u5931\u8D25\uFF0C\u4EE5\u4E0B\u662F\u4E0A\u4E0B\u6587\uFF1A
11
+
12
+ \u547D\u4EE4\uFF1A${t.lastCommand}
13
+ \u9000\u51FA\u7801\uFF1A${t.exitCode}
14
+ \u9519\u8BEF\u4FE1\u606F\uFF1A${t.errorOutput}
15
+ \u5F53\u524D\u76EE\u5F55\uFF1A${t.cwd}
16
+ \u64CD\u4F5C\u7CFB\u7EDF\uFF1A${t.os}
17
+ Shell\uFF1A${t.shell}`}async function F(t){try{let[e,o]=await Promise.all([y(),m()]),r=`${e.baseUrl.replace(/\/+$/,"")}/chat/completions`,i=JSON.stringify({model:e.model,messages:[{role:"system",content:D},{role:"user",content:G(t)}],temperature:.2,max_tokens:1024}),s=new AbortController,u=setTimeout(()=>s.abort(),o.timeoutMs),n;try{n=await fetch(r,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${e.apiKey}`},body:i,signal:s.signal})}finally{clearTimeout(u)}if(!n.ok){let j=await n.text();return console.error(`[llm] API returned status ${n.status}: ${j}`),null}let d=(await n.json())?.choices?.[0]?.message?.content;if(!d)return console.error("[llm] No content in response"),null;let a;try{a=JSON.parse(d)}catch{return console.error("[llm] Failed to parse LLM response as JSON:",d),null}let f=a.command??a.recommended_command??"";if(!f)return null;let l=String(a.confidence??"");return l&&!["high","medium","low"].includes(l)?{command:f}:{command:f,...l?{confidence:l}:{}}}catch(e){return e instanceof DOMException&&e.name==="AbortError"?console.error("[llm] Request timed out"):console.error("[llm] Request failed:",e),null}}import{readFile as S,writeFile as g,mkdir as J}from"fs/promises";import{join as k}from"path";import{homedir as O}from"os";function E(){return k(O(),"Documents","PowerShell","Microsoft.PowerShell_profile.ps1")}function H(){return`# >>> fuck init >>>
18
+
19
+ # CLI \u5165\u53E3\u8DEF\u5F84\uFF08\u901A\u8FC7 npm root -g \u52A8\u6001\u83B7\u53D6\uFF0C\u4E0D\u5199\u6B7B\u8DEF\u5F84\uFF09
20
+ $Fuck_NodeCli = "$(npm root -g)\\@sglwsjxh\\ffix\\dist\\main.js"
21
+
22
+ # \u91C7\u96C6\u5931\u8D25\u547D\u4EE4\u7684\u4E0A\u4E0B\u6587\u5230\u4E34\u65F6\u6587\u4EF6
23
+ function Write-FuckContext {
24
+ # \u51FD\u6570\u5F00\u5934\u7ACB\u523B\u4FDD\u5B58\u5173\u952E\u72B6\u6001\uFF0C\u907F\u514D\u540E\u7EED\u547D\u4EE4\u8986\u76D6
25
+ $lastSuccess = $?
26
+ $exitCode = $global:LASTEXITCODE
27
+
28
+ # Channel A: \u4F1A\u8BDD\u5386\u53F2\uFF08\u5FEB\u901F\u8DEF\u5F84\uFF09
29
+ $lastCmd = Get-History -Count 1 | Select-Object -ExpandProperty CommandLine -ErrorAction SilentlyContinue
30
+
31
+ # Channel B: PSReadLine \u5386\u53F2\u6587\u4EF6\u56DE\u9000\uFF08\u89E3\u51B3 PS7 \u5F02\u6B65\u5386\u53F2\u5BFC\u81F4 Get-History \u8FD4\u56DE null \u7684\u95EE\u9898\uFF09
32
+ if (-not $lastCmd) {
33
+ try {
34
+ $option = Get-PSReadLineOption -ErrorAction Stop
35
+ $historyPath = $option.HistorySavePath
36
+ if ($historyPath -and (Test-Path $historyPath)) {
37
+ $lines = Get-Content $historyPath -Tail 20 -ErrorAction Stop
38
+ $lastCmd = $lines |
39
+ Where-Object { $_ -and ($_ -notmatch '^s*fuck(s|$)') } |
40
+ Select-Object -Last 1
41
+ }
42
+ } catch {
43
+ # PSReadLine \u4E0D\u53EF\u7528\u65F6\u9759\u9ED8\u8DF3\u8FC7
44
+ }
45
+ }
46
+
47
+ if (-not $lastCmd) { return }
48
+
49
+ if ($exitCode -ne 0 -or -not $lastSuccess) {
50
+ $effectiveExitCode = if ($exitCode -ne 0) { $exitCode } else { 1 }
51
+ $errorMsg = if ($Error[0]) { $Error[0].Exception.Message } else { '' }
52
+ $ctx = @{
53
+ lastCommand = $lastCmd
54
+ exitCode = $effectiveExitCode
55
+ errorOutput = $errorMsg
56
+ cwd = (Get-Location).Path
57
+ shell = 'powershell-7'
58
+ os = 'win32'
59
+ timestamp = (Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fffZ')
60
+ }
61
+ $ctx | ConvertTo-Json -Compress | Out-File -FilePath "$env:TEMP\\fuck_ctx.json" -Encoding utf8
62
+ }
63
+ }
64
+
65
+ # \u4FDD\u5B58\u539F\u59CB prompt \u51FD\u6570\uFF0C\u786E\u4FDD\u4E0D\u7834\u574F\u7528\u6237\u81EA\u5B9A\u4E49\u7684 prompt
66
+ $Fuck_OriginalPrompt = \${function:prompt}
67
+
68
+ # \u91CD\u5199 prompt \u51FD\u6570\uFF1A\u5728\u6BCF\u6B21\u663E\u793A\u63D0\u793A\u7B26\u524D\u91C7\u96C6\u4E0A\u4E0B\u6587\uFF0C\u7136\u540E\u6062\u590D\u539F\u59CB\u884C\u4E3A
69
+ function prompt {
70
+ Write-FuckContext
71
+ & $Fuck_OriginalPrompt
72
+ }
73
+
74
+ # fuck \u547D\u4EE4\uFF1A\u8BFB\u53D6\u4E0A\u4E0B\u6587 \u2192 \u8C03\u7528 CLI\uFF08\u5E26\u786E\u8BA4\uFF09\u2192 \u6355\u83B7 stdout \u2192 iex \u6267\u884C
75
+ function fuck {
76
+ $ctxPath = "$env:TEMP\\fuck_ctx.json"
77
+ if (-not (Test-Path $ctxPath)) {
78
+ Write-Host "\u6CA1\u6709\u627E\u5230\u4E0A\u4E00\u6761\u547D\u4EE4\u7684\u4E0A\u4E0B\u6587"
79
+ return
80
+ }
81
+
82
+ $ctx = Get-Content $ctxPath -Raw | ConvertFrom-Json
83
+ if (-not $ctx) {
84
+ Write-Host "\u6CA1\u6709\u627E\u5230\u4E0A\u4E00\u6761\u547D\u4EE4\u7684\u4E0A\u4E0B\u6587"
85
+ return
86
+ }
87
+
88
+ # \u8BFB\u53D6\u540E\u7ACB\u5373\u5220\u9664\uFF0C\u907F\u514D\u88AB\u4E0B\u4E00\u8F6E prompt hook \u8986\u76D6
89
+ Remove-Item $ctxPath -Force -ErrorAction SilentlyContinue
90
+
91
+ $command = & node "$Fuck_NodeCli" --cmd "$($ctx.lastCommand)" --exit-code $ctx.exitCode --error-output "$($ctx.errorOutput)" --cwd "$($ctx.cwd)" --confirm
92
+
93
+ if (-not [string]::IsNullOrWhiteSpace($command)) {
94
+ iex "$command"
95
+ }
96
+
97
+ [Console]::ResetColor()
98
+ }
99
+
100
+ # <<< fuck init <<<`}async function A(){let t=E();await J(k(O(),"Documents","PowerShell"),{recursive:!0});let e="";try{e=await S(t,"utf-8")}catch{}if(e.includes("# >>> fuck init >>>")){console.log("fuck \u5DF2\u7ECF\u5B89\u88C5\u5230 $PROFILE\uFF0C\u8DF3\u8FC7");return}await g(t+".bak",e,"utf-8");let o=H();await g(t,e+(e?`
101
+ `:"")+o,"utf-8"),console.log(`\u5DF2\u5B89\u88C5\u5230 ${t}`)}async function T(){let t=E(),e;try{e=await S(t,"utf-8")}catch{console.log("\u6CA1\u6709\u627E\u5230 $PROFILE");return}let o="# >>> fuck init >>>",r="# <<< fuck init <<<",i=e.indexOf(o);if(i===-1){console.log("\u6CA1\u6709\u627E\u5230 fuck \u6CE8\u5165\u5185\u5BB9");return}let s=e.indexOf(r,i);if(s===-1){console.log("\u9519\u8BEF\uFF1A\u6CE8\u5165\u6807\u8BB0\u4E0D\u5B8C\u6574\uFF0C\u8BF7\u624B\u52A8\u68C0\u67E5 $PROFILE");return}let u=e.substring(0,i),n=e.substring(s+r.length),C=n.startsWith(`\r
102
+ `)?n.substring(2):n.startsWith(`
103
+ `)?n.substring(1):n;await g(t,u+C,"utf-8"),console.log("\u5DF2\u4ECE $PROFILE \u5378\u8F7D")}var x=t=>process.stderr.write(t);function W(){return new Promise(t=>{process.stdin.setRawMode(!0),process.stdin.resume(),process.stdin.once("data",e=>{process.stdin.pause(),process.stdin.setRawMode(!1),t(e.toString())})})}function q(t){let e={json:!1,quiet:!1,confirm:!1};for(let o=0;o<t.length;o++)switch(t[o]){case"install":e.subcommand="install";break;case"uninstall":e.subcommand="uninstall";break;case"--cmd":e.cmd=t[++o];break;case"--exit-code":e.exitCode=Number(t[++o]);break;case"--error-output":e.errorOutput=t[++o];break;case"--cwd":e.cwd=t[++o];break;case"--json":e.json=!0;break;case"--quiet":e.quiet=!0;break;case"--confirm":e.confirm=!0;break}return e}function K(t){return{lastCommand:t.cmd,exitCode:t.exitCode,errorOutput:t.errorOutput??"",cwd:t.cwd??process.cwd(),shell:"powershell-7",os:"win32",timestamp:new Date().toISOString()}}async function B(){let t=q(process.argv.slice(2));if(t.subcommand==="install"){await A();return}if(t.subcommand==="uninstall"){await T();return}await w();let e=null;t.cmd&&t.exitCode!==void 0&&!Number.isNaN(t.exitCode)?e=K(t):e=await b(),e||(console.error("\u6CA1\u6709\u627E\u5230\u4E0A\u4E00\u6761\u547D\u4EE4\u7684\u4E0A\u4E0B\u6587"),process.exit(1));let o=await F(e);if((!o||!o.command)&&(console.error("\u6CA1\u80FD\u627E\u5230\u4FEE\u590D\u65B9\u6848"),process.exit(1)),t.confirm){x(`\u4E0A\u4E00\u6761\u547D\u4EE4\uFF1A${e.lastCommand}
104
+
105
+ `),x(`\x1B[32m\u2726 \u5EFA\u8BAE\u6267\u884C\uFF1A${o.command}\x1B[0m
106
+
107
+ `),x("Enter = \u6267\u884C Ctrl+C = \u53D6\u6D88");try{let r=await W();(r==="\r"||r===`
108
+ `)&&(process.stderr.write(`
109
+
110
+ \x1B[32m> ${o.command}\x1B[0m
111
+ `),process.stdout.write(o.command),process.exit(0)),process.stderr.write(`
112
+
113
+ \u5DF2\u53D6\u6D88
114
+ `),process.exit(1)}catch{process.exit(1)}}t.json?console.log(JSON.stringify({command:o.command,confidence:o.confidence??"medium"})):(t.quiet,console.log(o.command)),process.exit(0)}B().catch(t=>{console.error(t),process.exit(1)});
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@sglwsjxh/ffix",
3
+ "version": "1.0.1",
4
+ "type": "module",
5
+ "bin": {
6
+ "fuck": "./dist/main.js"
7
+ },
8
+ "scripts": {
9
+ "dev": "tsx src/main.ts",
10
+ "build": "tsup src/main.ts --clean --minify --format esm",
11
+ "typecheck": "tsc --noEmit",
12
+ "prepublish": "npm run build"
13
+ },
14
+ "dependencies": {},
15
+ "devDependencies": {
16
+ "typescript": "*",
17
+ "tsup": "*",
18
+ "tsx": "*",
19
+ "@types/node": "*"
20
+ }
21
+ }
package/src/config.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+
5
+ import type { UserConfig, AppConfig } from './types.js'
6
+
7
+ const CONFIG_DIR = join(homedir(), '.ffix')
8
+ const CONFIG_PATH = join(CONFIG_DIR, 'config.json')
9
+
10
+ const DEFAULT_USER_CONFIG: UserConfig = {
11
+ baseUrl: '',
12
+ apiKey: '',
13
+ model: '',
14
+ }
15
+
16
+ const DEFAULT_APP_CONFIG: AppConfig = {
17
+ stderrTailLines: 20,
18
+ timeoutMs: 15_000,
19
+ tempFilePath: '%TEMP%\\fuck_ctx.json',
20
+ }
21
+
22
+ export async function ensureConfig(): Promise<void> {
23
+ try {
24
+ await readFile(CONFIG_PATH, 'utf-8')
25
+ } catch {
26
+ await mkdir(CONFIG_DIR, { recursive: true })
27
+ await writeFile(CONFIG_PATH, JSON.stringify(DEFAULT_USER_CONFIG, null, 4), 'utf-8')
28
+ console.log(`首次使用,请配置 API 信息:${CONFIG_PATH}`)
29
+ }
30
+ }
31
+
32
+ export async function loadUserConfig(): Promise<UserConfig> {
33
+ const raw = await readFile(CONFIG_PATH, 'utf-8')
34
+ const config: UserConfig = JSON.parse(raw)
35
+
36
+ if (!config.apiKey) {
37
+ throw new Error(`请在 ${CONFIG_PATH} 中配置 apiKey`)
38
+ }
39
+
40
+ return {
41
+ baseUrl: config.baseUrl || DEFAULT_USER_CONFIG.baseUrl,
42
+ apiKey: config.apiKey,
43
+ model: config.model || DEFAULT_USER_CONFIG.model,
44
+ }
45
+ }
46
+
47
+ export async function loadAppConfig(): Promise<AppConfig> {
48
+ return DEFAULT_APP_CONFIG
49
+ }
package/src/context.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type { FixContext } from './types.js'
2
+ import { readFile, unlink } from 'node:fs/promises'
3
+ import { loadAppConfig } from './config.js'
4
+
5
+ function resolveTempPath(template: string): string {
6
+ const tempDir = process.env.TEMP ?? process.env.TMPDIR ?? ''
7
+ return template.replace(/%TEMP%/g, tempDir)
8
+ }
9
+
10
+ export async function readContext(): Promise<FixContext | null> {
11
+ const appConfig = await loadAppConfig()
12
+ const filePath = resolveTempPath(appConfig.tempFilePath)
13
+ return readContextFromPath(filePath)
14
+ }
15
+
16
+ export async function readContextFromPath(filePath: string): Promise<FixContext | null> {
17
+ try {
18
+ const raw = await readFile(filePath, 'utf-8')
19
+ const ctx: FixContext = JSON.parse(raw)
20
+
21
+ if (typeof ctx.lastCommand !== 'string' || ctx.lastCommand.length === 0) return null
22
+ if (typeof ctx.exitCode !== 'number') return null
23
+
24
+ return ctx
25
+ } catch {
26
+ return null
27
+ } finally {
28
+ try { await unlink(filePath) } catch { }
29
+ }
30
+ }
package/src/llm.ts ADDED
@@ -0,0 +1,106 @@
1
+ import type { FixContext, FixSuggestion } from './types.js'
2
+ import { loadUserConfig, loadAppConfig } from './config.js'
3
+
4
+ const SYSTEM_PROMPT = `你是一个 PowerShell 7 命令修复助手,分析失败命令并给出修复命令
5
+
6
+ 严格要求:
7
+ 1. 只返回 JSON,不要多余文字
8
+ 2. JSON 格式严格固定为 {"command": "修复命令", "confidence": "high|medium|low"}
9
+ 3. command 必须是 PowerShell 7 可直接执行的一条命令
10
+ 4. 无法修复时返回 {"command": "", "confidence": "low"}
11
+ 5. 不要生成 bash/zsh 命令
12
+ 6. 不要假设用户想安装软件`
13
+
14
+ function buildUserPrompt(context: FixContext): string {
15
+ return `上一条命令执行失败,以下是上下文:
16
+
17
+ 命令:${context.lastCommand}
18
+ 退出码:${context.exitCode}
19
+ 错误信息:${context.errorOutput}
20
+ 当前目录:${context.cwd}
21
+ 操作系统:${context.os}
22
+ Shell:${context.shell}`
23
+ }
24
+
25
+ export async function getFixSuggestion(context: FixContext): Promise<FixSuggestion | null> {
26
+ try {
27
+ const [userConfig, appConfig] = await Promise.all([
28
+ loadUserConfig(),
29
+ loadAppConfig(),
30
+ ])
31
+
32
+ const url = `${userConfig.baseUrl.replace(/\/+$/, '')}/chat/completions`
33
+
34
+ const body = JSON.stringify({
35
+ model: userConfig.model,
36
+ messages: [
37
+ { role: 'system', content: SYSTEM_PROMPT },
38
+ { role: 'user', content: buildUserPrompt(context) },
39
+ ],
40
+ temperature: 0.2,
41
+ max_tokens: 1024,
42
+ })
43
+
44
+ const controller = new AbortController()
45
+ const timeoutId = setTimeout(() => controller.abort(), appConfig.timeoutMs)
46
+
47
+ let response: Response
48
+ try {
49
+ response = await fetch(url, {
50
+ method: 'POST',
51
+ headers: {
52
+ 'Content-Type': 'application/json',
53
+ 'Authorization': `Bearer ${userConfig.apiKey}`,
54
+ },
55
+ body,
56
+ signal: controller.signal,
57
+ })
58
+ } finally {
59
+ clearTimeout(timeoutId)
60
+ }
61
+
62
+ if (!response.ok) {
63
+ const responseBody = await response.text()
64
+ console.error(`[llm] API returned status ${response.status}: ${responseBody}`)
65
+ return null
66
+ }
67
+
68
+ const json = await response.json() as { choices?: Array<{ message?: { content?: string } }> }
69
+
70
+ const content = json?.choices?.[0]?.message?.content
71
+ if (!content) {
72
+ console.error('[llm] No content in response')
73
+ return null
74
+ }
75
+
76
+ let parsed: Record<string, unknown>
77
+ try {
78
+ parsed = JSON.parse(content)
79
+ } catch {
80
+ console.error('[llm] Failed to parse LLM response as JSON:', content)
81
+ return null
82
+ }
83
+
84
+ const cmd = (parsed.command ?? parsed.recommended_command ?? '') as string
85
+ if (!cmd) {
86
+ return null
87
+ }
88
+
89
+ const confidence = String(parsed.confidence ?? '') as FixSuggestion['confidence']
90
+ if (confidence && !['high', 'medium', 'low'].includes(confidence)) {
91
+ return { command: cmd }
92
+ }
93
+
94
+ return {
95
+ command: cmd,
96
+ ...(confidence ? { confidence } : {}),
97
+ }
98
+ } catch (err) {
99
+ if (err instanceof DOMException && err.name === 'AbortError') {
100
+ console.error('[llm] Request timed out')
101
+ } else {
102
+ console.error('[llm] Request failed:', err)
103
+ }
104
+ return null
105
+ }
106
+ }
package/src/main.ts ADDED
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readContext } from './context.js'
4
+ import { getFixSuggestion } from './llm.js'
5
+ import { install, uninstall } from './shell.js'
6
+ import { ensureConfig } from './config.js'
7
+ import type { FixContext } from './types.js'
8
+
9
+ const w = (s: string) => process.stderr.write(s)
10
+
11
+ function keyPress(): Promise<string> {
12
+ return new Promise((resolve) => {
13
+ process.stdin.setRawMode(true)
14
+ process.stdin.resume()
15
+ process.stdin.once('data', (data) => {
16
+ process.stdin.pause()
17
+ process.stdin.setRawMode(false)
18
+ resolve(data.toString())
19
+ })
20
+ })
21
+ }
22
+
23
+ interface CliArgs {
24
+ subcommand?: 'install' | 'uninstall'
25
+ cmd?: string
26
+ exitCode?: number
27
+ errorOutput?: string
28
+ cwd?: string
29
+ json: boolean
30
+ quiet: boolean
31
+ confirm: boolean
32
+ }
33
+
34
+ function parseArgs(argv: string[]): CliArgs {
35
+ const args: CliArgs = { json: false, quiet: false, confirm: false }
36
+
37
+ for (let i = 0; i < argv.length; i++) {
38
+ switch (argv[i]) {
39
+ case 'install':
40
+ args.subcommand = 'install'
41
+ break
42
+ case 'uninstall':
43
+ args.subcommand = 'uninstall'
44
+ break
45
+ case '--cmd':
46
+ args.cmd = argv[++i]
47
+ break
48
+ case '--exit-code':
49
+ args.exitCode = Number(argv[++i])
50
+ break
51
+ case '--error-output':
52
+ args.errorOutput = argv[++i]
53
+ break
54
+ case '--cwd':
55
+ args.cwd = argv[++i]
56
+ break
57
+ case '--json':
58
+ args.json = true
59
+ break
60
+ case '--quiet':
61
+ args.quiet = true
62
+ break
63
+ case '--confirm':
64
+ args.confirm = true
65
+ break
66
+ }
67
+ }
68
+
69
+ return args
70
+ }
71
+
72
+ function buildContextFromArgs(args: CliArgs): FixContext {
73
+ return {
74
+ lastCommand: args.cmd!,
75
+ exitCode: args.exitCode!,
76
+ errorOutput: args.errorOutput ?? '',
77
+ cwd: args.cwd ?? process.cwd(),
78
+ shell: 'powershell-7',
79
+ os: 'win32',
80
+ timestamp: new Date().toISOString(),
81
+ }
82
+ }
83
+
84
+ async function main(): Promise<void> {
85
+ const args = parseArgs(process.argv.slice(2))
86
+
87
+ if (args.subcommand === 'install') {
88
+ await install()
89
+ return
90
+ }
91
+
92
+ if (args.subcommand === 'uninstall') {
93
+ await uninstall()
94
+ return
95
+ }
96
+
97
+ await ensureConfig()
98
+
99
+ let context: FixContext | null = null
100
+
101
+ if (args.cmd && args.exitCode !== undefined && !Number.isNaN(args.exitCode)) {
102
+ context = buildContextFromArgs(args)
103
+ } else {
104
+ context = await readContext()
105
+ }
106
+
107
+ if (!context) {
108
+ console.error('没有找到上一条命令的上下文')
109
+ process.exit(1)
110
+ }
111
+
112
+ const suggestion = await getFixSuggestion(context)
113
+
114
+ if (!suggestion || !suggestion.command) {
115
+ console.error('没能找到修复方案')
116
+ process.exit(1)
117
+ }
118
+
119
+ if (args.confirm) {
120
+ w(`上一条命令:${context.lastCommand}\n\n`)
121
+ w(`\x1b[32m✦ 建议执行:${suggestion.command}\x1b[0m\n\n`)
122
+ w('Enter = 执行 Ctrl+C = 取消')
123
+
124
+ try {
125
+ const key = await keyPress()
126
+ if (key === '\r' || key === '\n') {
127
+ process.stderr.write(`\n\n\x1b[32m> ${suggestion.command}\x1b[0m\n`)
128
+ process.stdout.write(suggestion.command)
129
+ process.exit(0)
130
+ }
131
+ process.stderr.write('\n\n已取消\n')
132
+ process.exit(1)
133
+ } catch {
134
+ process.exit(1)
135
+ }
136
+ }
137
+
138
+ if (args.json) {
139
+ console.log(JSON.stringify({
140
+ command: suggestion.command,
141
+ confidence: suggestion.confidence ?? 'medium',
142
+ }))
143
+ } else if (args.quiet) {
144
+ console.log(suggestion.command)
145
+ } else {
146
+ console.log(suggestion.command)
147
+ }
148
+
149
+ process.exit(0)
150
+ }
151
+
152
+ main().catch((err: unknown) => {
153
+ console.error(err)
154
+ process.exit(1)
155
+ })
package/src/shell.ts ADDED
@@ -0,0 +1,184 @@
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+
5
+ function getProfilePath(): string {
6
+ return join(homedir(), 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1')
7
+ }
8
+
9
+ /**
10
+ * 检测当前是否在 PowerShell 7 环境中运行
11
+ * 通过检查 PSModulePath 环境变量是否包含 "PowerShell\" 来判断
12
+ */
13
+ export async function detectPs7(): Promise<boolean> {
14
+ return (process.env.PSModulePath ?? '').includes('PowerShell\\')
15
+ }
16
+
17
+ /**
18
+ * 生成要注入到 $PROFILE 的 PowerShell 脚本
19
+ * 内容包含:
20
+ * - 标记块 # >>> fuck init >>> / # <<< fuck init <<<
21
+ * - $Fuck_NodeCli 变量(指向 CLI 入口路径)
22
+ * - Write-FuckContext 函数(采集失败命令上下文)
23
+ * - prompt 函数重写(渲染前调用 Write-FuckContext)
24
+ * - fuck 命令(读取上下文 → 调用 LLM → 展示建议 → 确认执行)
25
+ */
26
+ export function generateProfileScript(): string {
27
+ return `# >>> fuck init >>>
28
+
29
+ # CLI 入口路径(通过 npm root -g 动态获取,不写死路径)
30
+ $Fuck_NodeCli = "$(npm root -g)\\@sglwsjxh\\ffix\\dist\\main.js"
31
+
32
+ # 采集失败命令的上下文到临时文件
33
+ function Write-FuckContext {
34
+ # 函数开头立刻保存关键状态,避免后续命令覆盖
35
+ $lastSuccess = $?
36
+ $exitCode = $global:LASTEXITCODE
37
+
38
+ # Channel A: 会话历史(快速路径)
39
+ $lastCmd = Get-History -Count 1 | Select-Object -ExpandProperty CommandLine -ErrorAction SilentlyContinue
40
+
41
+ # Channel B: PSReadLine 历史文件回退(解决 PS7 异步历史导致 Get-History 返回 null 的问题)
42
+ if (-not $lastCmd) {
43
+ try {
44
+ $option = Get-PSReadLineOption -ErrorAction Stop
45
+ $historyPath = $option.HistorySavePath
46
+ if ($historyPath -and (Test-Path $historyPath)) {
47
+ $lines = Get-Content $historyPath -Tail 20 -ErrorAction Stop
48
+ $lastCmd = $lines |
49
+ Where-Object { $_ -and ($_ -notmatch '^\s*fuck(\s|$)') } |
50
+ Select-Object -Last 1
51
+ }
52
+ } catch {
53
+ # PSReadLine 不可用时静默跳过
54
+ }
55
+ }
56
+
57
+ if (-not $lastCmd) { return }
58
+
59
+ if ($exitCode -ne 0 -or -not $lastSuccess) {
60
+ $effectiveExitCode = if ($exitCode -ne 0) { $exitCode } else { 1 }
61
+ $errorMsg = if ($Error[0]) { $Error[0].Exception.Message } else { '' }
62
+ $ctx = @{
63
+ lastCommand = $lastCmd
64
+ exitCode = $effectiveExitCode
65
+ errorOutput = $errorMsg
66
+ cwd = (Get-Location).Path
67
+ shell = 'powershell-7'
68
+ os = 'win32'
69
+ timestamp = (Get-Date -Format 'yyyy-MM-ddTHH:mm:ss.fffZ')
70
+ }
71
+ $ctx | ConvertTo-Json -Compress | Out-File -FilePath "$env:TEMP\\fuck_ctx.json" -Encoding utf8
72
+ }
73
+ }
74
+
75
+ # 保存原始 prompt 函数,确保不破坏用户自定义的 prompt
76
+ $Fuck_OriginalPrompt = \${function:prompt}
77
+
78
+ # 重写 prompt 函数:在每次显示提示符前采集上下文,然后恢复原始行为
79
+ function prompt {
80
+ Write-FuckContext
81
+ & $Fuck_OriginalPrompt
82
+ }
83
+
84
+ # fuck 命令:读取上下文 → 调用 CLI(带确认)→ 捕获 stdout → iex 执行
85
+ function fuck {
86
+ $ctxPath = "$env:TEMP\\fuck_ctx.json"
87
+ if (-not (Test-Path $ctxPath)) {
88
+ Write-Host "没有找到上一条命令的上下文"
89
+ return
90
+ }
91
+
92
+ $ctx = Get-Content $ctxPath -Raw | ConvertFrom-Json
93
+ if (-not $ctx) {
94
+ Write-Host "没有找到上一条命令的上下文"
95
+ return
96
+ }
97
+
98
+ # 读取后立即删除,避免被下一轮 prompt hook 覆盖
99
+ Remove-Item $ctxPath -Force -ErrorAction SilentlyContinue
100
+
101
+ $command = & node "$Fuck_NodeCli" --cmd "$($ctx.lastCommand)" --exit-code $ctx.exitCode --error-output "$($ctx.errorOutput)" --cwd "$($ctx.cwd)" --confirm
102
+
103
+ if (-not [string]::IsNullOrWhiteSpace($command)) {
104
+ iex "$command"
105
+ }
106
+
107
+ [Console]::ResetColor()
108
+ }
109
+
110
+ # <<< fuck init <<<`
111
+ }
112
+
113
+ /**
114
+ * 将 fuck 注入到当前用户的 $PROFILE 中
115
+ * 先备份原文件,再追加注入脚本
116
+ * 如果已经注入过则跳过
117
+ */
118
+ export async function install(): Promise<void> {
119
+ const profilePath = getProfilePath()
120
+
121
+ await mkdir(join(homedir(), 'Documents', 'PowerShell'), { recursive: true })
122
+
123
+ let content = ''
124
+ try {
125
+ content = await readFile(profilePath, 'utf-8')
126
+ } catch {
127
+ }
128
+
129
+ if (content.includes('# >>> fuck init >>>')) {
130
+ console.log('fuck 已经安装到 $PROFILE,跳过')
131
+ return
132
+ }
133
+
134
+ await writeFile(profilePath + '.bak', content, 'utf-8')
135
+
136
+ const script = generateProfileScript()
137
+ const separator = content ? '\n' : ''
138
+ await writeFile(profilePath, content + separator + script, 'utf-8')
139
+
140
+ console.log(`已安装到 ${profilePath}`)
141
+ }
142
+
143
+ /**
144
+ * 从 $PROFILE 中卸载 fuck 注入内容
145
+ * 只删除标记块之间的内容,不影响用户其他配置
146
+ */
147
+ export async function uninstall(): Promise<void> {
148
+ const profilePath = getProfilePath()
149
+
150
+ let content: string
151
+ try {
152
+ content = await readFile(profilePath, 'utf-8')
153
+ } catch {
154
+ console.log('没有找到 $PROFILE')
155
+ return
156
+ }
157
+
158
+ const startTag = '# >>> fuck init >>>'
159
+ const endTag = '# <<< fuck init <<<'
160
+
161
+ const startIdx = content.indexOf(startTag)
162
+ if (startIdx === -1) {
163
+ console.log('没有找到 fuck 注入内容')
164
+ return
165
+ }
166
+
167
+ const endIdx = content.indexOf(endTag, startIdx)
168
+ if (endIdx === -1) {
169
+ console.log('错误:注入标记不完整,请手动检查 $PROFILE')
170
+ return
171
+ }
172
+
173
+ // 提取标记块前后的内容,合并回文件
174
+ const before = content.substring(0, startIdx)
175
+ const after = content.substring(endIdx + endTag.length)
176
+ const trimmed = after.startsWith('\r\n')
177
+ ? after.substring(2)
178
+ : after.startsWith('\n')
179
+ ? after.substring(1)
180
+ : after
181
+
182
+ await writeFile(profilePath, before + trimmed, 'utf-8')
183
+ console.log('已从 $PROFILE 卸载')
184
+ }
package/src/types.ts ADDED
@@ -0,0 +1,26 @@
1
+ export interface UserConfig {
2
+ baseUrl: string
3
+ apiKey: string
4
+ model: string
5
+ }
6
+
7
+ export interface AppConfig {
8
+ stderrTailLines: number
9
+ timeoutMs: number
10
+ tempFilePath: string
11
+ }
12
+
13
+ export interface FixContext {
14
+ lastCommand: string
15
+ exitCode: number
16
+ errorOutput: string
17
+ cwd: string
18
+ shell: 'powershell-7'
19
+ os: 'win32'
20
+ timestamp: string
21
+ }
22
+
23
+ export interface FixSuggestion {
24
+ command: string
25
+ confidence?: 'high' | 'medium' | 'low'
26
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "strict": true,
4
+ "target": "ES2022",
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "outDir": "dist",
8
+ "rootDir": "src",
9
+ "esModuleInterop": true,
10
+ "types": ["node"],
11
+ "skipLibCheck": true
12
+ },
13
+ "include": ["src"]
14
+ }