@shawnstack/quickforge 1.0.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/LICENSE +21 -0
- package/README.md +131 -0
- package/bin/quickforge.mjs +172 -0
- package/dist/assets/anthropic-u1nbNXhV.js +39 -0
- package/dist/assets/azure-openai-responses-DQ6xSOmb.js +1 -0
- package/dist/assets/chunk-62oNxeRG.js +1 -0
- package/dist/assets/confirm-dialog-DSmrqQ60.js +1 -0
- package/dist/assets/github-copilot-headers-C0toI16e.js +1 -0
- package/dist/assets/google-OeyKMN12.js +1 -0
- package/dist/assets/google-gemini-cli-SnPixyBu.js +2 -0
- package/dist/assets/google-shared-CXUHW-9O.js +11 -0
- package/dist/assets/google-vertex-y0o2eCZV.js +1 -0
- package/dist/assets/hash-fDQBJsbb.js +1 -0
- package/dist/assets/headers-Drkm68SQ.js +1 -0
- package/dist/assets/index-BQJ8qi1U.css +3 -0
- package/dist/assets/index-CK_34smc.js +3048 -0
- package/dist/assets/mistral-DzE_jn-B.js +44 -0
- package/dist/assets/openai-CuiHR4mv.js +16 -0
- package/dist/assets/openai-codex-responses-MtFRvp_b.js +7 -0
- package/dist/assets/openai-completions-C2dhwzO8.js +5 -0
- package/dist/assets/openai-responses-C4n0VhzY.js +1 -0
- package/dist/assets/openai-responses-shared-D2RkRvTj.js +10 -0
- package/dist/assets/pdf.worker.min-Cpi8b8z3.mjs +28 -0
- package/dist/assets/prompt-dialog-B4BD09Oc.js +1 -0
- package/dist/assets/transform-messages-BFwlToJ0.js +1 -0
- package/dist/favicon.svg +1 -0
- package/dist/index.html +15 -0
- package/package.json +80 -0
- package/server/index.mjs +145 -0
- package/server/project-config.mjs +125 -0
- package/server/routes/filesystem.mjs +87 -0
- package/server/routes/instructions.mjs +31 -0
- package/server/routes/project.mjs +76 -0
- package/server/routes/static.mjs +57 -0
- package/server/routes/storage.mjs +97 -0
- package/server/routes/tools.mjs +31 -0
- package/server/storage.mjs +217 -0
- package/server/tools/index.mjs +236 -0
- package/server/utils/platform.mjs +131 -0
- package/server/utils/response.mjs +35 -0
- package/server/utils/workspace.mjs +135 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
|
|
3
|
+
import { readStore, writeStore, getComparable, storageDir } from '../storage.mjs'
|
|
4
|
+
import { directorySize } from '../utils/workspace.mjs'
|
|
5
|
+
|
|
6
|
+
export async function handleStorageApi(req, res, url) {
|
|
7
|
+
const parts = url.pathname.split('/').filter(Boolean)
|
|
8
|
+
|
|
9
|
+
if (req.method === 'GET' && url.pathname === '/api/storage/quota') {
|
|
10
|
+
const usage = await directorySize(storageDir)
|
|
11
|
+
sendJson(res, 200, { usage, quota: 0, percent: 0 })
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (parts[0] !== 'api' || parts[1] !== 'storage') {
|
|
16
|
+
const error = new Error('Not found')
|
|
17
|
+
error.statusCode = 404
|
|
18
|
+
throw error
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const store = decodeSegment(parts[2])
|
|
22
|
+
|
|
23
|
+
if (req.method === 'GET' && parts[3] === 'keys') {
|
|
24
|
+
const prefix = url.searchParams.get('prefix') || ''
|
|
25
|
+
const data = await readStore(store)
|
|
26
|
+
const keys = Object.keys(data).filter((key) => !prefix || key.startsWith(prefix))
|
|
27
|
+
sendJson(res, 200, { keys })
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (req.method === 'GET' && parts[3] === 'index') {
|
|
32
|
+
const indexName = decodeSegment(parts[4])
|
|
33
|
+
const direction = url.searchParams.get('direction') === 'desc' ? 'desc' : 'asc'
|
|
34
|
+
const data = await readStore(store)
|
|
35
|
+
const values = Object.values(data)
|
|
36
|
+
values.sort((a, b) => {
|
|
37
|
+
const left = getComparable(a, indexName)
|
|
38
|
+
const right = getComparable(b, indexName)
|
|
39
|
+
if (left === right) return 0
|
|
40
|
+
if (left === undefined || left === null) return direction === 'desc' ? 1 : -1
|
|
41
|
+
if (right === undefined || right === null) return direction === 'desc' ? -1 : 1
|
|
42
|
+
const result = String(left).localeCompare(String(right))
|
|
43
|
+
return direction === 'desc' ? -result : result
|
|
44
|
+
})
|
|
45
|
+
sendJson(res, 200, { values })
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (req.method === 'DELETE' && parts.length === 3) {
|
|
50
|
+
await writeStore(store, {})
|
|
51
|
+
sendJson(res, 200, { ok: true })
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (req.method === 'GET' && parts[3] === 'has') {
|
|
56
|
+
const key = decodeSegment(parts[4])
|
|
57
|
+
const data = await readStore(store)
|
|
58
|
+
sendJson(res, 200, { exists: Object.prototype.hasOwnProperty.call(data, key) })
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (parts[3] === 'key') {
|
|
63
|
+
const key = decodeSegment(parts[4])
|
|
64
|
+
if (!key) {
|
|
65
|
+
const error = new Error('Missing storage key')
|
|
66
|
+
error.statusCode = 400
|
|
67
|
+
throw error
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (req.method === 'GET') {
|
|
71
|
+
const data = await readStore(store)
|
|
72
|
+
sendJson(res, 200, { value: Object.prototype.hasOwnProperty.call(data, key) ? data[key] : null })
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (req.method === 'PUT') {
|
|
77
|
+
const body = await readJsonBody(req)
|
|
78
|
+
const data = await readStore(store)
|
|
79
|
+
data[key] = body?.value
|
|
80
|
+
await writeStore(store, data)
|
|
81
|
+
sendJson(res, 200, { ok: true })
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (req.method === 'DELETE') {
|
|
86
|
+
const data = await readStore(store)
|
|
87
|
+
delete data[key]
|
|
88
|
+
await writeStore(store, data)
|
|
89
|
+
sendJson(res, 200, { ok: true })
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const error = new Error('Not found')
|
|
95
|
+
error.statusCode = 404
|
|
96
|
+
throw error
|
|
97
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { sendJson, readJsonBody, decodeSegment } from '../utils/response.mjs'
|
|
2
|
+
import { toolHandlers } from '../tools/index.mjs'
|
|
3
|
+
import { projectContextFromId } from '../project-config.mjs'
|
|
4
|
+
|
|
5
|
+
export async function handleToolApi(req, res, url) {
|
|
6
|
+
if (req.method !== 'POST') {
|
|
7
|
+
const error = new Error('Tool endpoints require POST')
|
|
8
|
+
error.statusCode = 405
|
|
9
|
+
throw error
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const parts = url.pathname.split('/').filter(Boolean)
|
|
13
|
+
let name = decodeSegment(parts[2])
|
|
14
|
+
let context
|
|
15
|
+
|
|
16
|
+
if (parts[1] === 'projects' && parts[3] === 'tools') {
|
|
17
|
+
context = await projectContextFromId(decodeSegment(parts[2]))
|
|
18
|
+
name = decodeSegment(parts[4])
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const handler = toolHandlers[name]
|
|
22
|
+
if (!handler) {
|
|
23
|
+
const error = new Error(`Unknown tool: ${name}`)
|
|
24
|
+
error.statusCode = 404
|
|
25
|
+
throw error
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const params = await readJsonBody(req)
|
|
29
|
+
const result = await handler(params || {}, context)
|
|
30
|
+
sendJson(res, 200, result)
|
|
31
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { existsSync, promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
|
|
5
|
+
export const stores = new Set([
|
|
6
|
+
'settings',
|
|
7
|
+
'provider-keys',
|
|
8
|
+
'custom-providers',
|
|
9
|
+
'sessions',
|
|
10
|
+
'sessions-metadata',
|
|
11
|
+
])
|
|
12
|
+
|
|
13
|
+
function platformDataDir(appName) {
|
|
14
|
+
if (process.platform === 'win32') {
|
|
15
|
+
return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), appName)
|
|
16
|
+
}
|
|
17
|
+
if (process.platform === 'darwin') {
|
|
18
|
+
return path.join(os.homedir(), 'Library', 'Application Support', appName)
|
|
19
|
+
}
|
|
20
|
+
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), appName)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getDataDir() {
|
|
24
|
+
if (process.env.QUICKFORGE_DATA_DIR) return path.resolve(process.env.QUICKFORGE_DATA_DIR)
|
|
25
|
+
if (process.env.FASTCODE_DATA_DIR) return path.resolve(process.env.FASTCODE_DATA_DIR)
|
|
26
|
+
return path.join(os.homedir(), '.quickforge')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const dataDir = getDataDir()
|
|
30
|
+
export const storageDir = path.join(dataDir, 'storage')
|
|
31
|
+
|
|
32
|
+
export function storeFile(storeName) {
|
|
33
|
+
return path.join(storageDir, `${storeName}.json`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function projectConfigFile() {
|
|
37
|
+
return path.join(storageDir, 'project.json')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const writeQueues = new Map()
|
|
41
|
+
|
|
42
|
+
export async function ensureStorage() {
|
|
43
|
+
await fs.mkdir(storageDir, { recursive: true })
|
|
44
|
+
await Promise.all(
|
|
45
|
+
[...stores].map(async (store) => {
|
|
46
|
+
const file = storeFile(store)
|
|
47
|
+
if (!existsSync(file)) await fs.writeFile(file, '{}\n', 'utf8')
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function readStore(storeName) {
|
|
53
|
+
assertStore(storeName)
|
|
54
|
+
await ensureStorage()
|
|
55
|
+
const file = storeFile(storeName)
|
|
56
|
+
try {
|
|
57
|
+
const text = await fs.readFile(file, 'utf8')
|
|
58
|
+
return text.trim() ? JSON.parse(text) : {}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error?.code === 'ENOENT') return {}
|
|
61
|
+
throw error
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function writeStore(storeName, data) {
|
|
66
|
+
assertStore(storeName)
|
|
67
|
+
const previous = writeQueues.get(storeName) || Promise.resolve()
|
|
68
|
+
const next = previous
|
|
69
|
+
.catch(() => undefined)
|
|
70
|
+
.then(async () => {
|
|
71
|
+
await ensureStorage()
|
|
72
|
+
const file = storeFile(storeName)
|
|
73
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
74
|
+
await fs.writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
|
|
75
|
+
await fs.rename(tmp, file)
|
|
76
|
+
})
|
|
77
|
+
writeQueues.set(storeName, next)
|
|
78
|
+
return next
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function assertStore(storeName) {
|
|
82
|
+
if (!stores.has(storeName)) {
|
|
83
|
+
const error = new Error(`Unknown storage store: ${storeName}`)
|
|
84
|
+
error.statusCode = 404
|
|
85
|
+
throw error
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function getComparable(value, key) {
|
|
90
|
+
if (!value || typeof value !== 'object') return undefined
|
|
91
|
+
return key.split('.').reduce((current, part) => {
|
|
92
|
+
if (!current || typeof current !== 'object') return undefined
|
|
93
|
+
return current[part]
|
|
94
|
+
}, value)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function directoryExists(file) {
|
|
98
|
+
try {
|
|
99
|
+
await fs.access(file)
|
|
100
|
+
return true
|
|
101
|
+
} catch {
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function readJsonObject(file) {
|
|
107
|
+
try {
|
|
108
|
+
const text = await fs.readFile(file, 'utf8')
|
|
109
|
+
const parsed = text.trim() ? JSON.parse(text) : {}
|
|
110
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
|
|
111
|
+
} catch {
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function mergeJsonObjectFile(sourceFile, targetFile) {
|
|
117
|
+
const source = await readJsonObject(sourceFile)
|
|
118
|
+
if (!source) return false
|
|
119
|
+
|
|
120
|
+
const target = (await readJsonObject(targetFile)) ?? {}
|
|
121
|
+
let changed = false
|
|
122
|
+
for (const [key, value] of Object.entries(source)) {
|
|
123
|
+
if (Object.hasOwn(target, key)) continue
|
|
124
|
+
target[key] = value
|
|
125
|
+
changed = true
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!changed) return false
|
|
129
|
+
|
|
130
|
+
await fs.mkdir(path.dirname(targetFile), { recursive: true })
|
|
131
|
+
const tmp = `${targetFile}.${process.pid}.${Date.now()}.tmp`
|
|
132
|
+
await fs.writeFile(tmp, `${JSON.stringify(target, null, 2)}\n`, 'utf8')
|
|
133
|
+
await fs.rename(tmp, targetFile)
|
|
134
|
+
return true
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function copyMissingRecursive(source, target) {
|
|
138
|
+
const stat = await fs.stat(source).catch(() => null)
|
|
139
|
+
if (!stat) return false
|
|
140
|
+
|
|
141
|
+
if (stat.isDirectory()) {
|
|
142
|
+
await fs.mkdir(target, { recursive: true })
|
|
143
|
+
let copied = false
|
|
144
|
+
const entries = await fs.readdir(source, { withFileTypes: true })
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
copied = (await copyMissingRecursive(path.join(source, entry.name), path.join(target, entry.name))) || copied
|
|
147
|
+
}
|
|
148
|
+
return copied
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!stat.isFile() || (await directoryExists(target))) return false
|
|
152
|
+
|
|
153
|
+
await fs.mkdir(path.dirname(target), { recursive: true })
|
|
154
|
+
await fs.copyFile(source, target)
|
|
155
|
+
return true
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function uniquePaths(paths) {
|
|
159
|
+
const seen = new Set()
|
|
160
|
+
return paths.filter((item) => {
|
|
161
|
+
const resolved = path.resolve(item)
|
|
162
|
+
const key = process.platform === 'win32' ? resolved.toLowerCase() : resolved
|
|
163
|
+
if (seen.has(key)) return false
|
|
164
|
+
seen.add(key)
|
|
165
|
+
return true
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function migrateLegacyDataDir(sourceDir) {
|
|
170
|
+
const resolvedSource = path.resolve(sourceDir)
|
|
171
|
+
const resolvedTarget = path.resolve(dataDir)
|
|
172
|
+
const sourceKey = process.platform === 'win32' ? resolvedSource.toLowerCase() : resolvedSource
|
|
173
|
+
const targetKey = process.platform === 'win32' ? resolvedTarget.toLowerCase() : resolvedTarget
|
|
174
|
+
if (sourceKey === targetKey) return false
|
|
175
|
+
|
|
176
|
+
const sourceStorageDir = path.join(resolvedSource, 'storage')
|
|
177
|
+
if (!(await directoryExists(sourceStorageDir))) return false
|
|
178
|
+
|
|
179
|
+
await fs.mkdir(storageDir, { recursive: true })
|
|
180
|
+
|
|
181
|
+
let migrated = false
|
|
182
|
+
for (const store of stores) {
|
|
183
|
+
const sourceFile = path.join(sourceStorageDir, `${store}.json`)
|
|
184
|
+
const targetFile = storeFile(store)
|
|
185
|
+
if (!(await directoryExists(sourceFile))) continue
|
|
186
|
+
if (await directoryExists(targetFile)) {
|
|
187
|
+
migrated = (await mergeJsonObjectFile(sourceFile, targetFile)) || migrated
|
|
188
|
+
} else {
|
|
189
|
+
await fs.copyFile(sourceFile, targetFile)
|
|
190
|
+
migrated = true
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const sourceProjectFile = path.join(sourceStorageDir, 'project.json')
|
|
195
|
+
if ((await directoryExists(sourceProjectFile)) && !(await directoryExists(projectConfigFile()))) {
|
|
196
|
+
await fs.copyFile(sourceProjectFile, projectConfigFile())
|
|
197
|
+
migrated = true
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
migrated = (await copyMissingRecursive(sourceStorageDir, storageDir)) || migrated
|
|
201
|
+
if (migrated) console.log(`Migrated legacy QuickForge data from ${resolvedSource} to ${resolvedTarget}`)
|
|
202
|
+
return migrated
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function migrateLegacyDataDirs() {
|
|
206
|
+
if (process.env.QUICKFORGE_DATA_DIR || process.env.FASTCODE_DATA_DIR) return
|
|
207
|
+
|
|
208
|
+
const legacyDirs = uniquePaths([
|
|
209
|
+
platformDataDir('QuickForge'),
|
|
210
|
+
platformDataDir('FastCode'),
|
|
211
|
+
path.join(os.homedir(), '.fastcode'),
|
|
212
|
+
])
|
|
213
|
+
|
|
214
|
+
for (const dir of legacyDirs) {
|
|
215
|
+
await migrateLegacyDataDir(dir)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { resolveWorkspacePath, toWorkspaceRelative, assertSafeWorkspacePath, truncateText, splitLines, shouldSkipSearchDir, shouldSearchFile, isSensitiveWorkspacePath } from '../utils/workspace.mjs'
|
|
5
|
+
import { readProjectConfig, getActiveProject } from '../project-config.mjs'
|
|
6
|
+
import { getWorkspaceRoot, getToolWorkspaceRoot } from '../utils/workspace.mjs'
|
|
7
|
+
|
|
8
|
+
// --- list_dir ---
|
|
9
|
+
export async function toolListDir(params, context) {
|
|
10
|
+
const dir = resolveWorkspacePath(params?.path || '.', context)
|
|
11
|
+
assertSafeWorkspacePath(dir, context)
|
|
12
|
+
|
|
13
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
14
|
+
const rows = await Promise.all(entries.map(async (entry) => {
|
|
15
|
+
const fullPath = path.join(dir, entry.name)
|
|
16
|
+
const stat = await fs.stat(fullPath).catch(() => null)
|
|
17
|
+
return {
|
|
18
|
+
name: `${entry.name}${entry.isDirectory() ? '/' : ''}`,
|
|
19
|
+
type: entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other',
|
|
20
|
+
size: stat?.size ?? 0,
|
|
21
|
+
modified: stat?.mtime?.toISOString?.() ?? '',
|
|
22
|
+
}
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
rows.sort((a, b) => {
|
|
26
|
+
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1
|
|
27
|
+
return a.name.localeCompare(b.name)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const content = rows.length
|
|
31
|
+
? rows.map((row) => `${row.type.padEnd(9)} ${String(row.size).padStart(10)} ${row.modified} ${row.name}`).join('\n')
|
|
32
|
+
: '(empty directory)'
|
|
33
|
+
|
|
34
|
+
return { content, details: { path: toWorkspaceRelative(dir, context), project: context?.project, count: rows.length } }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// --- read_file ---
|
|
38
|
+
export async function toolReadFile(params, context) {
|
|
39
|
+
const file = resolveWorkspacePath(params?.path, context)
|
|
40
|
+
assertSafeWorkspacePath(file, context)
|
|
41
|
+
|
|
42
|
+
const text = await fs.readFile(file, 'utf8')
|
|
43
|
+
const lines = splitLines(text)
|
|
44
|
+
const offset = Math.max(1, Number(params?.offset || 1))
|
|
45
|
+
const limit = Math.min(2000, Math.max(1, Number(params?.limit || 200)))
|
|
46
|
+
const selected = lines.slice(offset - 1, offset - 1 + limit)
|
|
47
|
+
const content = selected.map((line, index) => `${offset + index}: ${line}`).join('\n')
|
|
48
|
+
const suffix = offset - 1 + limit < lines.length ? `\n\n[showing ${selected.length} of ${lines.length} lines]` : ''
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
content: truncateText(`${content}${suffix}`),
|
|
52
|
+
details: { path: toWorkspaceRelative(file, context), project: context?.project, totalLines: lines.length, offset, limit },
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- grep_files ---
|
|
57
|
+
export async function toolGrepFiles(params, context) {
|
|
58
|
+
const root = resolveWorkspacePath(params?.path || '.', context)
|
|
59
|
+
assertSafeWorkspacePath(root, context)
|
|
60
|
+
|
|
61
|
+
const query = String(params?.query || '')
|
|
62
|
+
if (!query) {
|
|
63
|
+
const error = new Error('query is required')
|
|
64
|
+
error.statusCode = 400
|
|
65
|
+
throw error
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const limit = Math.min(1000, Math.max(1, Number(params?.limit || 200)))
|
|
69
|
+
const flags = params?.caseSensitive ? 'g' : 'gi'
|
|
70
|
+
const matcher = params?.regex
|
|
71
|
+
? new RegExp(query, flags)
|
|
72
|
+
: new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags)
|
|
73
|
+
|
|
74
|
+
const files = await walkFiles(root, [], context)
|
|
75
|
+
const matches = []
|
|
76
|
+
|
|
77
|
+
for (const file of files) {
|
|
78
|
+
if (matches.length >= limit) break
|
|
79
|
+
const stat = await fs.stat(file)
|
|
80
|
+
if (stat.size > 1024 * 1024) continue
|
|
81
|
+
|
|
82
|
+
const text = await fs.readFile(file, 'utf8').catch(() => '')
|
|
83
|
+
const lines = splitLines(text)
|
|
84
|
+
for (let index = 0; index < lines.length && matches.length < limit; index++) {
|
|
85
|
+
matcher.lastIndex = 0
|
|
86
|
+
if (matcher.test(lines[index])) {
|
|
87
|
+
matches.push(`${toWorkspaceRelative(file, context)}:${index + 1}: ${lines[index]}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
content: matches.length ? truncateText(matches.join('\n')) : 'No matches found.',
|
|
94
|
+
details: { path: toWorkspaceRelative(root, context), project: context?.project, query, count: matches.length, limit },
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- write_file ---
|
|
99
|
+
export async function toolWriteFile(params, context) {
|
|
100
|
+
const file = resolveWorkspacePath(params?.path, context)
|
|
101
|
+
assertSafeWorkspacePath(file, context)
|
|
102
|
+
|
|
103
|
+
await fs.mkdir(path.dirname(file), { recursive: true })
|
|
104
|
+
await fs.writeFile(file, String(params?.content ?? ''), 'utf8')
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
content: `Wrote ${toWorkspaceRelative(file, context)}`,
|
|
108
|
+
details: { path: toWorkspaceRelative(file, context), project: context?.project, bytes: Buffer.byteLength(String(params?.content ?? ''), 'utf8') },
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- edit_file ---
|
|
113
|
+
function countOccurrences(text, needle) {
|
|
114
|
+
if (!needle) return 0
|
|
115
|
+
let count = 0
|
|
116
|
+
let index = 0
|
|
117
|
+
while ((index = text.indexOf(needle, index)) !== -1) {
|
|
118
|
+
count++
|
|
119
|
+
index += needle.length
|
|
120
|
+
}
|
|
121
|
+
return count
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function toolEditFile(params, context) {
|
|
125
|
+
const file = resolveWorkspacePath(params?.path, context)
|
|
126
|
+
assertSafeWorkspacePath(file, context)
|
|
127
|
+
|
|
128
|
+
const oldText = String(params?.oldText ?? '')
|
|
129
|
+
const newText = String(params?.newText ?? '')
|
|
130
|
+
const text = await fs.readFile(file, 'utf8')
|
|
131
|
+
const count = countOccurrences(text, oldText)
|
|
132
|
+
|
|
133
|
+
if (count !== 1) {
|
|
134
|
+
const error = new Error(`oldText must match exactly once; found ${count} matches`)
|
|
135
|
+
error.statusCode = 400
|
|
136
|
+
throw error
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await fs.writeFile(file, text.replace(oldText, newText), 'utf8')
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
content: `Edited ${toWorkspaceRelative(file, context)}`,
|
|
143
|
+
details: { path: toWorkspaceRelative(file, context), project: context?.project, replaced: count },
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- run_command ---
|
|
148
|
+
export async function toolRunCommand(params, context) {
|
|
149
|
+
const command = String(params?.command || '')
|
|
150
|
+
if (!command.trim()) {
|
|
151
|
+
const error = new Error('command is required')
|
|
152
|
+
error.statusCode = 400
|
|
153
|
+
throw error
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const timeoutMs = Math.min(10 * 60, Math.max(1, Number(params?.timeoutSeconds || 60))) * 1000
|
|
157
|
+
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
const child = spawn(command, {
|
|
160
|
+
cwd: getToolWorkspaceRoot(context),
|
|
161
|
+
shell: true,
|
|
162
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
163
|
+
windowsHide: true,
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
let stdout = ''
|
|
167
|
+
let stderr = ''
|
|
168
|
+
let timedOut = false
|
|
169
|
+
const timer = setTimeout(() => {
|
|
170
|
+
timedOut = true
|
|
171
|
+
child.kill('SIGTERM')
|
|
172
|
+
}, timeoutMs)
|
|
173
|
+
|
|
174
|
+
child.stdout.on('data', (chunk) => {
|
|
175
|
+
stdout = truncateText(stdout + chunk.toString())
|
|
176
|
+
})
|
|
177
|
+
child.stderr.on('data', (chunk) => {
|
|
178
|
+
stderr = truncateText(stderr + chunk.toString())
|
|
179
|
+
})
|
|
180
|
+
child.on('close', (code, signal) => {
|
|
181
|
+
clearTimeout(timer)
|
|
182
|
+
const content = [
|
|
183
|
+
`Command: ${command}`,
|
|
184
|
+
`Exit code: ${code ?? 'unknown'}${signal ? `, signal: ${signal}` : ''}${timedOut ? ' (timed out)' : ''}`,
|
|
185
|
+
'',
|
|
186
|
+
'STDOUT:',
|
|
187
|
+
stdout || '(empty)',
|
|
188
|
+
'',
|
|
189
|
+
'STDERR:',
|
|
190
|
+
stderr || '(empty)',
|
|
191
|
+
].join('\n')
|
|
192
|
+
resolve({ content: truncateText(content), details: { command, project: context?.project, cwd: getToolWorkspaceRoot(context), code, signal, timedOut } })
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// --- get_project_info ---
|
|
198
|
+
export async function toolGetProjectInfo(_params, context) {
|
|
199
|
+
if (context?.project) {
|
|
200
|
+
return {
|
|
201
|
+
content: `Project: ${context.project.name}\nRoot: ${context.project.path}`,
|
|
202
|
+
details: { project: context.project, workspaceRoot: context.workspaceRoot },
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const config = await readProjectConfig()
|
|
207
|
+
const project = getActiveProject(config)
|
|
208
|
+
return {
|
|
209
|
+
content: `Active project: ${project.name}\nRoot: ${project.path}`,
|
|
210
|
+
details: { project, workspaceRoot: getWorkspaceRoot() },
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Helper for grep
|
|
215
|
+
async function walkFiles(root, files = [], context) {
|
|
216
|
+
const entries = await fs.readdir(root, { withFileTypes: true })
|
|
217
|
+
for (const entry of entries) {
|
|
218
|
+
const fullPath = path.join(root, entry.name)
|
|
219
|
+
if (entry.isDirectory()) {
|
|
220
|
+
if (!shouldSkipSearchDir(entry.name)) await walkFiles(fullPath, files, context)
|
|
221
|
+
} else if (entry.isFile() && shouldSearchFile(entry.name) && !isSensitiveWorkspacePath(fullPath, context)) {
|
|
222
|
+
files.push(fullPath)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return files
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const toolHandlers = {
|
|
229
|
+
get_project_info: toolGetProjectInfo,
|
|
230
|
+
list_dir: toolListDir,
|
|
231
|
+
read_file: toolReadFile,
|
|
232
|
+
grep_files: toolGrepFiles,
|
|
233
|
+
write_file: toolWriteFile,
|
|
234
|
+
edit_file: toolEditFile,
|
|
235
|
+
run_command: toolRunCommand,
|
|
236
|
+
}
|