@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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/bin/quickforge.mjs +172 -0
  4. package/dist/assets/anthropic-u1nbNXhV.js +39 -0
  5. package/dist/assets/azure-openai-responses-DQ6xSOmb.js +1 -0
  6. package/dist/assets/chunk-62oNxeRG.js +1 -0
  7. package/dist/assets/confirm-dialog-DSmrqQ60.js +1 -0
  8. package/dist/assets/github-copilot-headers-C0toI16e.js +1 -0
  9. package/dist/assets/google-OeyKMN12.js +1 -0
  10. package/dist/assets/google-gemini-cli-SnPixyBu.js +2 -0
  11. package/dist/assets/google-shared-CXUHW-9O.js +11 -0
  12. package/dist/assets/google-vertex-y0o2eCZV.js +1 -0
  13. package/dist/assets/hash-fDQBJsbb.js +1 -0
  14. package/dist/assets/headers-Drkm68SQ.js +1 -0
  15. package/dist/assets/index-BQJ8qi1U.css +3 -0
  16. package/dist/assets/index-CK_34smc.js +3048 -0
  17. package/dist/assets/mistral-DzE_jn-B.js +44 -0
  18. package/dist/assets/openai-CuiHR4mv.js +16 -0
  19. package/dist/assets/openai-codex-responses-MtFRvp_b.js +7 -0
  20. package/dist/assets/openai-completions-C2dhwzO8.js +5 -0
  21. package/dist/assets/openai-responses-C4n0VhzY.js +1 -0
  22. package/dist/assets/openai-responses-shared-D2RkRvTj.js +10 -0
  23. package/dist/assets/pdf.worker.min-Cpi8b8z3.mjs +28 -0
  24. package/dist/assets/prompt-dialog-B4BD09Oc.js +1 -0
  25. package/dist/assets/transform-messages-BFwlToJ0.js +1 -0
  26. package/dist/favicon.svg +1 -0
  27. package/dist/index.html +15 -0
  28. package/package.json +80 -0
  29. package/server/index.mjs +145 -0
  30. package/server/project-config.mjs +125 -0
  31. package/server/routes/filesystem.mjs +87 -0
  32. package/server/routes/instructions.mjs +31 -0
  33. package/server/routes/project.mjs +76 -0
  34. package/server/routes/static.mjs +57 -0
  35. package/server/routes/storage.mjs +97 -0
  36. package/server/routes/tools.mjs +31 -0
  37. package/server/storage.mjs +217 -0
  38. package/server/tools/index.mjs +236 -0
  39. package/server/utils/platform.mjs +131 -0
  40. package/server/utils/response.mjs +35 -0
  41. 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
+ }