thincoder 0.9.0 → 0.11.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.
- package/README.md +6 -1
- package/package.json +1 -1
- package/src/cli/make-agent.mjs +7 -0
- package/src/config.mjs +46 -19
- package/src/context.mjs +18 -0
- package/src/prompts/coder.md +5 -2
- package/src/prompts/discipline.md +8 -5
- package/src/prompts/system.md +8 -5
- package/src/provider/anthropic.mjs +190 -0
- package/src/provider/core.mjs +36 -4
- package/src/provider/google.mjs +197 -0
- package/src/proxy.mjs +236 -0
- package/src/tools/codemode.mjs +178 -0
- package/src/tools/fetch.md +2 -1
- package/src/tools/git.mjs +120 -154
- package/src/tools/index.mjs +11 -9
- package/src/tools/linter.mjs +46 -32
- package/src/tools/lsp.mjs +317 -0
- package/src/tools/web.mjs +103 -82
- package/src/tools/websearch.md +5 -3
- package/src/tui/agent-turn.mjs +12 -13
- package/src/tui/cmd-advisor.mjs +29 -41
- package/src/tui/cmd-clear.mjs +11 -17
- package/src/tui/cmd-config.mjs +226 -142
- package/src/tui/cmd-extract.mjs +1 -1
- package/src/tui/cmd-fold.mjs +2 -3
- package/src/tui/cmd-goal.mjs +58 -27
- package/src/tui/cmd-help.mjs +3 -1
- package/src/tui/cmd-mcp.mjs +178 -142
- package/src/tui/cmd-model.mjs +15 -4
- package/src/tui/cmd-new.mjs +7 -13
- package/src/tui/cmd-restore.mjs +12 -16
- package/src/tui/cmd-session.mjs +28 -32
- package/src/tui/cmd-think.mjs +75 -50
- package/src/tui/cmd-undo.mjs +19 -23
- package/src/tui/cmd-upgrade.mjs +22 -26
- package/src/tui/index.mjs +64 -38
- package/src/tui/key-handler.mjs +48 -18
- package/src/tui/layout.mjs +12 -2
- package/src/tui/pickers.mjs +151 -182
- package/src/tui/render-frame.mjs +29 -11
- package/src/tui/slash-commands.mjs +26 -16
package/src/tools/git.mjs
CHANGED
|
@@ -6,101 +6,133 @@ import {
|
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
|
|
9
|
-
export const
|
|
10
|
-
name: "
|
|
11
|
-
description:
|
|
9
|
+
export const gitTool = {
|
|
10
|
+
name: "git",
|
|
11
|
+
description:
|
|
12
|
+
"Run a git command. Use this to see uncommitted changes, staged changes, diff against a ref, recent commits, or manage checkpoints. Only works inside a git repository.\n" +
|
|
13
|
+
"- action='diff': Show unified diff — what changed since last commit. Set staged=true for staged-only diff, ref=<ref> to compare against a specific commit/branch, path=<dir> to scope to a file or directory.\n" +
|
|
14
|
+
"- action='status': Show working tree state — staged, unstaged, untracked files, and conflicts. Returns categorized lists.\n" +
|
|
15
|
+
"- action='log': Show recent commit history. Set count to limit, oneline=true for compact format, path=<file> to see history of one file.\n" +
|
|
16
|
+
"- action='checkpoint': Manage git-based snapshots. Use checkpointAction to choose: list (overview), create (snapshot now), rewind (restore snapshot by id), cat (read a file from a snapshot).",
|
|
12
17
|
parameters: {
|
|
13
18
|
type: "object",
|
|
14
19
|
properties: {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
action: { type: "string", enum: ["diff", "status", "log", "checkpoint"], description: "diff / status / log / checkpoint" },
|
|
21
|
+
// diff/log params
|
|
22
|
+
staged: { type: "boolean", description: "(diff) Show staged changes instead of working tree" },
|
|
23
|
+
path: { type: "string", description: "(diff/log/checkpoint:cat/checkpoint:rewind) File or directory to scope to" },
|
|
24
|
+
ref: { type: "string", description: "(diff) Compare against this ref (default HEAD)" },
|
|
25
|
+
count: { type: "number", description: "(log) Number of commits (default 10)" },
|
|
26
|
+
oneline: { type: "boolean", description: "(log) One-line-per-commit format" },
|
|
27
|
+
// checkpoint params
|
|
28
|
+
checkpointAction: { type: "string", enum: ["list", "create", "rewind", "cat"], description: "(checkpoint) list snapshots / create one / restore by id / read file from snapshot" },
|
|
29
|
+
checkpointId: { type: "string", description: "(checkpoint) Snapshot id — required for rewind and cat; optional for list (shows file tree)" },
|
|
18
30
|
},
|
|
31
|
+
required: ["action"],
|
|
19
32
|
},
|
|
20
|
-
readonly:
|
|
21
|
-
execute(args, ctx) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
33
|
+
readonly: false,
|
|
34
|
+
async execute(args, ctx) {
|
|
35
|
+
switch (args.action) {
|
|
36
|
+
case "diff": {
|
|
37
|
+
const ref = args.ref ?? "HEAD"
|
|
38
|
+
if (!/^[A-Za-z0-9._\/~^][A-Za-z0-9._\/~^-]*$/.test(ref)) throw new Error(`Invalid git ref: ${ref}`)
|
|
39
|
+
const flags = args.staged ? ["--staged"] : []
|
|
40
|
+
const paths = args.path ? [args.path] : []
|
|
41
|
+
const out = runGit(ctx.cwd, ["diff", ...flags, ref, "--", ...paths])
|
|
42
|
+
return truncate(out || "(no changes)")
|
|
43
|
+
}
|
|
44
|
+
case "status": {
|
|
45
|
+
const porcelain = runGit(ctx.cwd, ["status", "--porcelain"])
|
|
46
|
+
if (!porcelain) return "(clean — no changes)"
|
|
47
|
+
|
|
48
|
+
const staged = []
|
|
49
|
+
const unstaged = []
|
|
50
|
+
const untracked = []
|
|
51
|
+
const conflicts = []
|
|
52
|
+
for (const line of porcelain.split("\n")) {
|
|
53
|
+
if (!line) continue
|
|
54
|
+
const clean = line.replace(/\r/g, "")
|
|
55
|
+
const m = clean.match(/^(..?)\s+(.+)$/)
|
|
56
|
+
if (!m) continue
|
|
57
|
+
const [, status, rawFile] = m
|
|
58
|
+
const file = status.includes("R") && rawFile.includes(" -> ") ? rawFile.replace(" -> ", " → ") : rawFile
|
|
59
|
+
const idx = status[0] ?? " "
|
|
60
|
+
const wt = status[1] ?? " "
|
|
61
|
+
if (idx === "U" || wt === "U" || (idx === "A" && wt === "A")) {
|
|
62
|
+
conflicts.push(file)
|
|
63
|
+
} else if (idx === "?" && wt === "?") {
|
|
64
|
+
untracked.push(file)
|
|
65
|
+
} else {
|
|
66
|
+
if (idx !== " " && idx !== "?") staged.push(idx + " " + file)
|
|
67
|
+
if (wt !== " " && wt !== "?") unstaged.push(wt + " " + file)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const parts = []
|
|
71
|
+
if (staged.length) parts.push("Staged (" + staged.length + "):\n" + staged.join("\n"))
|
|
72
|
+
if (unstaged.length) parts.push("Unstaged (" + unstaged.length + "):\n" + unstaged.join("\n"))
|
|
73
|
+
if (untracked.length) parts.push("Untracked (" + untracked.length + "):\n" + untracked.join("\n"))
|
|
74
|
+
if (conflicts.length) parts.push("Conflicts (" + conflicts.length + "):\n" + conflicts.join("\n"))
|
|
75
|
+
return truncate(parts.join("\n\n"))
|
|
76
|
+
}
|
|
77
|
+
case "log": {
|
|
78
|
+
const n = Math.min(Math.max(1, args.count ?? 10), 200)
|
|
79
|
+
const isOneline = args.oneline
|
|
80
|
+
const cmdArgs = isOneline
|
|
81
|
+
? ["log", "-" + n, "--oneline"]
|
|
82
|
+
: ["log", "-" + n, "--format=%h %ad %an %s", "--date=short"]
|
|
83
|
+
if (args.path) cmdArgs.push("--", args.path)
|
|
84
|
+
const out = runGit(ctx.cwd, cmdArgs)
|
|
85
|
+
return truncate(out || "(no commits)")
|
|
70
86
|
}
|
|
87
|
+
case "checkpoint": {
|
|
88
|
+
const { createCheckpoint, listCheckpoints, rewind, isGitRepo } = await import("../git/checkpoint.mjs")
|
|
89
|
+
if (!isGitRepo(ctx.cwd)) throw new Error("Not a git repository — checkpoints unavailable")
|
|
90
|
+
|
|
91
|
+
const sub = args.checkpointAction
|
|
92
|
+
if (!sub) return "checkpoint: missing checkpointAction — use: list | create | rewind | cat"
|
|
93
|
+
|
|
94
|
+
if (sub === "create") {
|
|
95
|
+
const cp = await createCheckpoint(ctx.cwd)
|
|
96
|
+
return `Checkpoint ${cp.id} created (${cp.files} file(s): ${cp.tracked.length} tracked, ${cp.untracked.length} untracked)`
|
|
97
|
+
}
|
|
98
|
+
if (sub === "rewind") {
|
|
99
|
+
if (!args.checkpointId) throw new Error("checkpointId is required for rewind — use checkpointAction=list to see snapshot ids")
|
|
100
|
+
const s = await rewind(ctx.cwd, args.checkpointId, { path: args.path })
|
|
101
|
+
if (args.path) {
|
|
102
|
+
return `Restored "${args.path}" (${s.type}) from checkpoint ${args.checkpointId}.\n(The pre-rewind state was snapshotted first — you can rewind again to go back.)`
|
|
103
|
+
}
|
|
104
|
+
return `Rewound to checkpoint ${args.checkpointId}: patch ${s.patchApplied ? "applied" : "(empty)"}, ${s.restored ?? 0} untracked file(s) restored, ${s.deleted ?? 0} file(s) deleted.\n(The pre-rewind state was snapshotted first — you can rewind again to go back.)`
|
|
105
|
+
}
|
|
106
|
+
if (sub === "cat") {
|
|
107
|
+
if (!args.checkpointId) throw new Error("checkpointId is required for cat — use checkpointAction=list to see snapshot ids")
|
|
108
|
+
if (!args.path) throw new Error("path is required for cat — specify which file to read")
|
|
109
|
+
const { catFile } = await import("../git/checkpoint.mjs")
|
|
110
|
+
return await catFile(ctx.cwd, args.checkpointId, args.path)
|
|
111
|
+
}
|
|
112
|
+
if (sub === "list") {
|
|
113
|
+
const cps = await listCheckpoints(ctx.cwd)
|
|
114
|
+
if (cps.length === 0) return "(no checkpoints yet — one is auto-created before each user task)"
|
|
115
|
+
|
|
116
|
+
// Specific id: show the file tree within that snapshot
|
|
117
|
+
if (args.checkpointId) {
|
|
118
|
+
const cp = cps.find((c) => c.id === args.checkpointId)
|
|
119
|
+
if (!cp) throw new Error(`checkpoint ${args.checkpointId} not found`)
|
|
120
|
+
return formatFileTree(cp)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Overview: list of all snapshots
|
|
124
|
+
return cps.map((c) => {
|
|
125
|
+
const parts = [`${c.id} ${new Date(c.time).toISOString()}`]
|
|
126
|
+
if (c.tracked.length) parts.push(`${c.tracked.length} tracked: ${c.tracked.join(", ")}`)
|
|
127
|
+
if (c.untracked.length) parts.push(`${c.untracked.length} untracked: ${c.untracked.join(", ")}`)
|
|
128
|
+
return parts.join(" ")
|
|
129
|
+
}).join("\n")
|
|
130
|
+
}
|
|
131
|
+
throw new Error(`Unknown checkpoint action: ${sub}. Use: list | create | rewind | cat`)
|
|
132
|
+
}
|
|
133
|
+
default:
|
|
134
|
+
return `Unknown action '${args.action}'. Use: diff | status | log | checkpoint`
|
|
71
135
|
}
|
|
72
|
-
const parts = []
|
|
73
|
-
if (staged.length) parts.push("Staged (" + staged.length + "):\n" + staged.join("\n"))
|
|
74
|
-
if (unstaged.length) parts.push("Unstaged (" + unstaged.length + "):\n" + unstaged.join("\n"))
|
|
75
|
-
if (untracked.length) parts.push("Untracked (" + untracked.length + "):\n" + untracked.join("\n"))
|
|
76
|
-
if (conflicts.length) parts.push("Conflicts (" + conflicts.length + "):\n" + conflicts.join("\n"))
|
|
77
|
-
return truncate(parts.join("\n\n"))
|
|
78
|
-
},
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// ---------------------------------------------------------------- git_log
|
|
82
|
-
|
|
83
|
-
export const gitLogTool = {
|
|
84
|
-
name: "git_log",
|
|
85
|
-
description: DESC("git_log"),
|
|
86
|
-
parameters: {
|
|
87
|
-
type: "object",
|
|
88
|
-
properties: {
|
|
89
|
-
count: { type: "number", description: "Number of commits (default 10)" },
|
|
90
|
-
path: { type: "string", description: "File or directory (default all)" },
|
|
91
|
-
oneline: { type: "boolean", description: "One-line-per-commit format (default false)" },
|
|
92
|
-
},
|
|
93
|
-
},
|
|
94
|
-
readonly: true,
|
|
95
|
-
execute(args, ctx) {
|
|
96
|
-
const n = Math.min(Math.max(1, args.count ?? 10), 200)
|
|
97
|
-
const isOneline = args.oneline
|
|
98
|
-
const cmdArgs = isOneline
|
|
99
|
-
? ["log", "-" + n, "--oneline"]
|
|
100
|
-
: ["log", "-" + n, "--format=%h %ad %an %s", "--date=short"]
|
|
101
|
-
if (args.path) cmdArgs.push("--", args.path)
|
|
102
|
-
const out = runGit(ctx.cwd, cmdArgs)
|
|
103
|
-
return truncate(out || "(no commits)")
|
|
104
136
|
},
|
|
105
137
|
}
|
|
106
138
|
|
|
@@ -128,67 +160,6 @@ export const questionTool = {
|
|
|
128
160
|
},
|
|
129
161
|
}
|
|
130
162
|
|
|
131
|
-
/** Execute a git command; returns empty string when not a git repo / git unavailable */
|
|
132
|
-
|
|
133
|
-
// ---------------------------------------------------------------- checkpoint
|
|
134
|
-
|
|
135
|
-
export const checkpointTool = {
|
|
136
|
-
name: "checkpoint",
|
|
137
|
-
description: DESC("checkpoint"),
|
|
138
|
-
parameters: {
|
|
139
|
-
type: "object",
|
|
140
|
-
properties: {
|
|
141
|
-
action: { type: "string", enum: ["list", "create", "rewind", "cat"], description: "list snapshots / create one now / restore a snapshot by id / read a file's content from a snapshot" },
|
|
142
|
-
id: { type: "string", description: "Snapshot id (required for rewind and cat; optional for list — shows file tree of that snapshot)" },
|
|
143
|
-
path: { type: "string", description: "Restore only this single file from the checkpoint (tracked or untracked). Other files are left untouched." },
|
|
144
|
-
},
|
|
145
|
-
required: ["action"],
|
|
146
|
-
},
|
|
147
|
-
readonly: false,
|
|
148
|
-
async execute(args, ctx) {
|
|
149
|
-
const { createCheckpoint, listCheckpoints, rewind, isGitRepo } = await import("../git/checkpoint.mjs")
|
|
150
|
-
if (!isGitRepo(ctx.cwd)) throw new Error("Not a git repository — checkpoints unavailable")
|
|
151
|
-
if (args.action === "create") {
|
|
152
|
-
const cp = await createCheckpoint(ctx.cwd)
|
|
153
|
-
return `Checkpoint ${cp.id} created (${cp.files} file(s): ${cp.tracked.length} tracked, ${cp.untracked.length} untracked)`
|
|
154
|
-
}
|
|
155
|
-
if (args.action === "rewind") {
|
|
156
|
-
if (!args.id) throw new Error("id is required for rewind — use action=list to see snapshot ids")
|
|
157
|
-
const s = await rewind(ctx.cwd, args.id, { path: args.path })
|
|
158
|
-
if (args.path) {
|
|
159
|
-
return `Restored "${args.path}" (${s.type}) from checkpoint ${args.id}.\n(The pre-rewind state was snapshotted first — you can rewind again to go back.)`
|
|
160
|
-
}
|
|
161
|
-
return `Rewound to checkpoint ${args.id}: patch ${s.patchApplied ? "applied" : "(empty)"}, ${s.restored ?? 0} untracked file(s) restored, ${s.deleted ?? 0} file(s) deleted.\n(The pre-rewind state was snapshotted first — you can rewind again to go back.)`
|
|
162
|
-
}
|
|
163
|
-
if (args.action === "cat") {
|
|
164
|
-
if (!args.id) throw new Error("id is required for cat — use action=list to see snapshot ids")
|
|
165
|
-
if (!args.path) throw new Error("path is required for cat — specify which file to read")
|
|
166
|
-
const { catFile } = await import("../git/checkpoint.mjs")
|
|
167
|
-
return await catFile(ctx.cwd, args.id, args.path)
|
|
168
|
-
}
|
|
169
|
-
if (args.action === "list") {
|
|
170
|
-
const cps = await listCheckpoints(ctx.cwd)
|
|
171
|
-
if (cps.length === 0) return "(no checkpoints yet — one is auto-created before each user task)"
|
|
172
|
-
|
|
173
|
-
// Specific id: show the file tree within that snapshot
|
|
174
|
-
if (args.id) {
|
|
175
|
-
const cp = cps.find((c) => c.id === args.id)
|
|
176
|
-
if (!cp) throw new Error(`checkpoint ${args.id} not found`)
|
|
177
|
-
return formatFileTree(cp)
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Overview: list of all snapshots
|
|
181
|
-
return cps.map((c) => {
|
|
182
|
-
const parts = [`${c.id} ${new Date(c.time).toISOString()}`]
|
|
183
|
-
if (c.tracked.length) parts.push(`${c.tracked.length} tracked: ${c.tracked.join(", ")}`)
|
|
184
|
-
if (c.untracked.length) parts.push(`${c.untracked.length} untracked: ${c.untracked.join(", ")}`)
|
|
185
|
-
return parts.join(" ")
|
|
186
|
-
}).join("\n")
|
|
187
|
-
}
|
|
188
|
-
throw new Error(`Unknown action: ${args.action}`)
|
|
189
|
-
},
|
|
190
|
-
}
|
|
191
|
-
|
|
192
163
|
/** Format a checkpoint's file list as a directory tree (directories first, indented display) */
|
|
193
164
|
function formatFileTree(cp) {
|
|
194
165
|
const all = [
|
|
@@ -197,10 +168,8 @@ function formatFileTree(cp) {
|
|
|
197
168
|
]
|
|
198
169
|
if (all.length === 0) return "(empty checkpoint)"
|
|
199
170
|
|
|
200
|
-
// Sort by path (directories group naturally)
|
|
201
171
|
all.sort((a, b) => a.path.localeCompare(b.path))
|
|
202
172
|
|
|
203
|
-
// Build directory → file list map
|
|
204
173
|
const tree = new Map()
|
|
205
174
|
for (const { path, type } of all) {
|
|
206
175
|
const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "."
|
|
@@ -208,12 +177,10 @@ function formatFileTree(cp) {
|
|
|
208
177
|
tree.get(dir).push({ name: path.slice(dir === "." ? 0 : dir.length + 1), type })
|
|
209
178
|
}
|
|
210
179
|
|
|
211
|
-
// Output sorted by directory
|
|
212
180
|
const lines = []
|
|
213
181
|
const dirs = [...tree.keys()].sort()
|
|
214
182
|
for (const dir of dirs) {
|
|
215
183
|
if (dir !== "." && !lines.includes(dir + "/")) {
|
|
216
|
-
// Parent directory before child directory
|
|
217
184
|
const parts = dir.split("/")
|
|
218
185
|
for (let i = 1; i <= parts.length; i++) {
|
|
219
186
|
const prefix = parts.slice(0, i).join("/") + "/"
|
|
@@ -221,7 +188,6 @@ function formatFileTree(cp) {
|
|
|
221
188
|
}
|
|
222
189
|
}
|
|
223
190
|
}
|
|
224
|
-
// Ensure directories precede files
|
|
225
191
|
for (const dir of dirs) {
|
|
226
192
|
if (dir !== ".") {
|
|
227
193
|
for (const { name, type } of tree.get(dir)) {
|
package/src/tools/index.mjs
CHANGED
|
@@ -2,25 +2,27 @@
|
|
|
2
2
|
export { toOpenAISchema } from "./shared.mjs";
|
|
3
3
|
|
|
4
4
|
import { readTool, writeTool, editTool, insertAfterTool, readImageTool, hashlineEditTool } from "./file.mjs";
|
|
5
|
-
import { applyPatchTool,
|
|
5
|
+
import { applyPatchTool, deleteTool } from "./patch.mjs";
|
|
6
6
|
import { bashTool, globTool, grepTool, lsTool } from "./system.mjs";
|
|
7
7
|
import { websearchTool, fetchTool } from "./web.mjs";
|
|
8
|
-
import {
|
|
8
|
+
import { gitTool, questionTool } from "./git.mjs";
|
|
9
9
|
import { checklistTool } from "./checklist.mjs";
|
|
10
|
-
import {
|
|
10
|
+
import { lintTool } from "./linter.mjs";
|
|
11
|
+
import { lspTool } from "./lsp.mjs";
|
|
12
|
+
import { codeModeTool } from "./codemode.mjs";
|
|
11
13
|
|
|
12
14
|
export const builtinTools = [
|
|
13
15
|
readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
|
|
14
|
-
|
|
16
|
+
readImageTool, bashTool, globTool, grepTool,
|
|
15
17
|
websearchTool, lsTool, fetchTool, deleteTool,
|
|
16
|
-
|
|
17
|
-
checklistTool,
|
|
18
|
+
gitTool, questionTool,
|
|
19
|
+
checklistTool, lintTool, lspTool, codeModeTool,
|
|
18
20
|
];
|
|
19
21
|
|
|
20
22
|
export {
|
|
21
23
|
readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
|
|
22
|
-
|
|
24
|
+
readImageTool, bashTool, globTool, grepTool,
|
|
23
25
|
websearchTool, lsTool, fetchTool, deleteTool,
|
|
24
|
-
|
|
25
|
-
checklistTool,
|
|
26
|
+
gitTool, questionTool,
|
|
27
|
+
checklistTool, lintTool, lspTool, codeModeTool,
|
|
26
28
|
};
|
package/src/tools/linter.mjs
CHANGED
|
@@ -1,38 +1,66 @@
|
|
|
1
1
|
import { DESC, resolveInCwd } from "./shared.mjs"
|
|
2
|
+
import { execFileSync } from "node:child_process"
|
|
3
|
+
import { existsSync } from "node:fs"
|
|
4
|
+
import { join, relative } from "node:path"
|
|
2
5
|
|
|
3
|
-
export const
|
|
4
|
-
name: "
|
|
5
|
-
description:
|
|
6
|
+
export const lintTool = {
|
|
7
|
+
name: "lint",
|
|
8
|
+
description:
|
|
9
|
+
"Run the appropriate linter/checker for a file. Auto-detects based on file extension and project config.\n" +
|
|
10
|
+
"Without 'full', runs a fast node --check (JS/TS syntax only, catches parse errors in milliseconds).\n" +
|
|
11
|
+
"With 'full', runs the language-aware cascade: eslint → tsc –noEmit → node --check (JS/TS/TSX); ruff (Python); cargo check (Rust); go vet (Go).\n" +
|
|
12
|
+
"Use the fast default after every write/edit; use 'full' before declaring a task complete.",
|
|
6
13
|
parameters: {
|
|
7
14
|
type: "object",
|
|
8
15
|
properties: {
|
|
9
|
-
path: { type: "string", description: "File
|
|
16
|
+
path: { type: "string", description: "File to check (default: most recently modified file)" },
|
|
17
|
+
full: { type: "boolean", description: "Run the full language-aware cascade instead of just node --check (default false)" },
|
|
10
18
|
},
|
|
19
|
+
required: [],
|
|
11
20
|
},
|
|
12
21
|
readonly: true,
|
|
13
22
|
async execute(args, ctx) {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (!abs) return "linter: no file specified and no recently modified file to check"
|
|
23
|
+
const abs = args.path
|
|
24
|
+
? resolveInCwd(ctx, args.path)
|
|
25
|
+
: (ctx.agent?._touchedFiles?.at(-1) || null)
|
|
26
|
+
if (!abs) return "lint: no file specified and no recently modified file to check"
|
|
19
27
|
|
|
28
|
+
if (!args.full) {
|
|
29
|
+
// Fast path: node --check only
|
|
30
|
+
return nodeCheckResult(abs)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Full cascade: language-aware (eslint → tsc → node --check, etc.)
|
|
20
34
|
const ext = abs.split(".").pop()?.toLowerCase()
|
|
21
35
|
const checkers = LANG_CHECKERS[ext]
|
|
22
|
-
if (!checkers) return
|
|
36
|
+
if (!checkers) return nodeCheckResult(abs) // fall back to node --check
|
|
23
37
|
|
|
24
38
|
for (const checker of checkers) {
|
|
25
39
|
const result = await checker(abs, { cwd: ctx.cwd, existsSync, execFileSync, join, relative })
|
|
26
40
|
if (result !== null) return result
|
|
27
41
|
}
|
|
28
|
-
return `
|
|
42
|
+
return `lint: no linter available for ${abs}. Install one?`
|
|
29
43
|
},
|
|
30
44
|
}
|
|
31
45
|
|
|
32
|
-
|
|
46
|
+
function nodeCheckResult(abs) {
|
|
47
|
+
if (!/\.(?:m?js|cjs|m?ts|cts|jsx|tsx)$/.test(abs)) {
|
|
48
|
+
return `lint (check): only JS/TS-family files supported for fast syntax check; use full=true for other languages. Path: ${abs}`
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
execFileSync(process.execPath, ["--check", abs], {
|
|
52
|
+
encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"],
|
|
53
|
+
})
|
|
54
|
+
return `Syntax OK: ${abs}`
|
|
55
|
+
} catch (e) {
|
|
56
|
+
const msg = (e.stderr || e.stdout || e.message || "").trim()
|
|
57
|
+
return `Syntax error in ${abs}:\n${msg || "(unknown)"}`
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Full-check cascade checkers ──────────────────────
|
|
33
62
|
|
|
34
63
|
async function eslintCheck(file, { cwd, existsSync, execFileSync, join, relative }) {
|
|
35
|
-
// Walk up to find eslint config
|
|
36
64
|
let dir = file.split(/[\\/]/).slice(0, -1).join("/") || "."
|
|
37
65
|
while (true) {
|
|
38
66
|
for (const cfg of [".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yaml", ".eslintrc.yml", "eslint.config.js", "eslint.config.mjs"]) {
|
|
@@ -73,18 +101,6 @@ async function tscCheck(file, { cwd, existsSync, execFileSync, join }) {
|
|
|
73
101
|
}
|
|
74
102
|
}
|
|
75
103
|
|
|
76
|
-
async function nodeCheck(file, { execFileSync, cwd }) {
|
|
77
|
-
if (!/\.(m?js|cjs)$/.test(file)) return null
|
|
78
|
-
try {
|
|
79
|
-
execFileSync(process.execPath, ["--check", file], {
|
|
80
|
-
cwd, encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"],
|
|
81
|
-
})
|
|
82
|
-
return "✓ node --check: Syntax OK"
|
|
83
|
-
} catch (e) {
|
|
84
|
-
return `✗ node --check: ${(e.stderr || e.message).slice(0, 500)}`
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
104
|
async function ruffCheck(file, { cwd, execFileSync }) {
|
|
89
105
|
if (!/\.py$/.test(file)) return null
|
|
90
106
|
try {
|
|
@@ -93,7 +109,7 @@ async function ruffCheck(file, { cwd, execFileSync }) {
|
|
|
93
109
|
})
|
|
94
110
|
return "✓ ruff: no issues"
|
|
95
111
|
} catch (e) {
|
|
96
|
-
if (e.code === "ENOENT") return "
|
|
112
|
+
if (e.code === "ENOENT") return "lint: ruff not installed. Run: pip install ruff"
|
|
97
113
|
const stdout = (e.stdout || "").trim()
|
|
98
114
|
if (stdout) return stdout
|
|
99
115
|
return `✗ ruff: ${(e.stderr || e.message).slice(0, 500)}`
|
|
@@ -129,13 +145,11 @@ async function goVet(file, { cwd, execFileSync }) {
|
|
|
129
145
|
}
|
|
130
146
|
}
|
|
131
147
|
|
|
132
|
-
// ─── Language → checkers (first available wins) ──
|
|
133
|
-
|
|
134
148
|
const LANG_CHECKERS = {
|
|
135
|
-
js: [eslintCheck
|
|
136
|
-
mjs: [eslintCheck
|
|
137
|
-
cjs: [eslintCheck
|
|
138
|
-
jsx: [eslintCheck
|
|
149
|
+
js: [eslintCheck],
|
|
150
|
+
mjs: [eslintCheck],
|
|
151
|
+
cjs: [eslintCheck],
|
|
152
|
+
jsx: [eslintCheck],
|
|
139
153
|
ts: [eslintCheck, tscCheck],
|
|
140
154
|
tsx: [eslintCheck, tscCheck],
|
|
141
155
|
mts: [eslintCheck, tscCheck],
|