@shawnstack/quickforge 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +22 -16
  2. package/bin/quickforge.mjs +83 -8
  3. package/dist/assets/{anthropic-u1nbNXhV.js → anthropic-DLvtwHL2.js} +2 -2
  4. package/dist/assets/{azure-openai-responses-DQ6xSOmb.js → azure-openai-responses-D68z7hLN.js} +1 -1
  5. package/dist/assets/css-utils-rkE68RDy.js +1 -0
  6. package/dist/assets/{google-OeyKMN12.js → google-B_sSaRBM.js} +1 -1
  7. package/dist/assets/{google-gemini-cli-SnPixyBu.js → google-gemini-cli-CYqGXjGi.js} +1 -1
  8. package/dist/assets/google-shared-XhYUKiGZ.js +11 -0
  9. package/dist/assets/{google-vertex-y0o2eCZV.js → google-vertex-DSMuB4YB.js} +1 -1
  10. package/dist/assets/icons-BsZ9PlYY.js +1 -0
  11. package/dist/assets/index-BqFfVQJM.css +3 -0
  12. package/dist/assets/{index-CK_34smc.js → index-DoraECXN.js} +801 -662
  13. package/dist/assets/lit-vendor-1dsGB-Iy.js +2 -0
  14. package/dist/assets/{mistral-DzE_jn-B.js → mistral-BZngRB4x.js} +2 -2
  15. package/dist/assets/{openai-codex-responses-MtFRvp_b.js → openai-codex-responses-Niu7xDYK.js} +1 -1
  16. package/dist/assets/openai-completions-B2bhb9k0.js +5 -0
  17. package/dist/assets/{openai-responses-C4n0VhzY.js → openai-responses-CDYDv8yL.js} +1 -1
  18. package/dist/assets/{openai-responses-shared-D2RkRvTj.js → openai-responses-shared-BIKPTpEQ.js} +1 -1
  19. package/dist/assets/react-vendor-Ds3ovY0w.js +9 -0
  20. package/dist/assets/rolldown-runtime-CkqCuyE9.js +1 -0
  21. package/dist/index.html +7 -3
  22. package/package.json +2 -1
  23. package/server/agent-manager.mjs +1053 -0
  24. package/server/conversation-compaction.mjs +302 -0
  25. package/server/custom-commands.mjs +344 -0
  26. package/server/index.mjs +326 -34
  27. package/server/project-config.mjs +85 -55
  28. package/server/reasoning-cache.mjs +51 -0
  29. package/server/restart-supervisor.mjs +38 -0
  30. package/server/routes/agent.mjs +323 -0
  31. package/server/routes/backup.mjs +250 -0
  32. package/server/routes/instructions.mjs +6 -17
  33. package/server/routes/project.mjs +49 -19
  34. package/server/routes/scheduled-tasks.mjs +424 -0
  35. package/server/routes/shared-conversation.mjs +404 -0
  36. package/server/routes/shares.mjs +84 -0
  37. package/server/routes/skills.mjs +145 -0
  38. package/server/routes/static.mjs +4 -3
  39. package/server/routes/storage.mjs +66 -12
  40. package/server/routes/system.mjs +35 -0
  41. package/server/routes/tools.mjs +53 -2
  42. package/server/session-utils.mjs +102 -0
  43. package/server/share-store.mjs +468 -0
  44. package/server/skills.mjs +539 -0
  45. package/server/storage.mjs +578 -133
  46. package/server/system-prompt.mjs +67 -0
  47. package/server/tools/definitions.mjs +120 -0
  48. package/server/tools/index.mjs +167 -46
  49. package/server/utils/logger.mjs +34 -0
  50. package/server/utils/network.mjs +38 -0
  51. package/server/utils/platform.mjs +31 -1
  52. package/server/utils/response.mjs +9 -2
  53. package/skills/ai-context-package/SKILL.md +104 -0
  54. package/skills/ai-context-package/skill.json +9 -0
  55. package/skills/code-review/SKILL.md +23 -0
  56. package/skills/code-review/skill.json +9 -0
  57. package/skills/frontend-react/SKILL.md +22 -0
  58. package/skills/frontend-react/skill.json +9 -0
  59. package/skills/quickforge-project/SKILL.md +22 -0
  60. package/skills/quickforge-project/skill.json +9 -0
  61. package/dist/assets/chunk-62oNxeRG.js +0 -1
  62. package/dist/assets/confirm-dialog-DSmrqQ60.js +0 -1
  63. package/dist/assets/google-shared-CXUHW-9O.js +0 -11
  64. package/dist/assets/index-BQJ8qi1U.css +0 -3
  65. package/dist/assets/openai-completions-C2dhwzO8.js +0 -5
  66. package/dist/assets/prompt-dialog-B4BD09Oc.js +0 -1
  67. /package/dist/assets/{github-copilot-headers-C0toI16e.js → github-copilot-headers-CrI0CIJ7.js} +0 -0
  68. /package/dist/assets/{hash-fDQBJsbb.js → hash-Bt1aVMQ3.js} +0 -0
  69. /package/dist/assets/{headers-Drkm68SQ.js → headers-5EYI0_pl.js} +0 -0
  70. /package/dist/assets/{openai-CuiHR4mv.js → openai-Cn7eGqwa.js} +0 -0
  71. /package/dist/assets/{transform-messages-BFwlToJ0.js → transform-messages-CV4kCtBB.js} +0 -0
@@ -0,0 +1,67 @@
1
+ export const BASE_SYSTEM_PROMPT = `You are a pragmatic coding assistant.
2
+
3
+ For project tasks:
4
+ - Inspect the workspace before changing files.
5
+ - Make minimal, focused changes.
6
+ - Prefer dedicated workspace tools for reading, editing, and searching files.
7
+ - If dedicated tools are unavailable or insufficient, use the shell/command tool.
8
+ - Use Python through the shell for reliable scripting, data processing, or file transformations.
9
+ - Stay within the current workspace unless the user explicitly asks otherwise.
10
+ - Verify changes with relevant tests, build, lint, or targeted checks.
11
+ - If no suitable tool is available, say so clearly.`
12
+
13
+ function escapeXml(value) {
14
+ return String(value ?? '')
15
+ .replace(/&/g, '&')
16
+ .replace(/</g, '&lt;')
17
+ .replace(/>/g, '&gt;')
18
+ }
19
+
20
+ function formatSkillCatalogItem(skill) {
21
+ const details = [
22
+ ` <name>${escapeXml(skill.name)}</name>`,
23
+ ` <description>${escapeXml(skill.description)}</description>`,
24
+ ]
25
+
26
+ if (skill.compatibility) details.push(` <compatibility>${escapeXml(skill.compatibility)}</compatibility>`)
27
+ if (skill.allowedTools) details.push(` <allowed_tools>${escapeXml(skill.allowedTools)}</allowed_tools>`)
28
+
29
+ return ` <skill>\n${details.join('\n')}\n </skill>`
30
+ }
31
+
32
+ function appendSkillsCatalog(parts, skills) {
33
+ if (!Array.isArray(skills) || skills.length === 0) return
34
+
35
+ const skillParts = skills.map(formatSkillCatalogItem)
36
+ parts.push(`
37
+ <available_skills>
38
+ The following Agent Skills provide specialized instructions for specific tasks. Use progressive disclosure: this catalog is available now, but full skill instructions are loaded only when needed.
39
+
40
+ When the user's task matches a skill description, call activate_skill with that skill's name before proceeding. If a loaded skill references bundled files under scripts/, references/, or assets/, call read_skill_resource with the skill name and the relative path. Do not assume resources are already loaded.
41
+
42
+ ${skillParts.join('\n')}
43
+ </available_skills>`)
44
+ }
45
+
46
+ export function composeSystemPrompt(instructions = {}) {
47
+ const parts = [BASE_SYSTEM_PROMPT]
48
+
49
+ if (instructions.global) {
50
+ parts.push(`\n<user_instructions>\n${instructions.global}\n</user_instructions>`)
51
+ }
52
+
53
+ if (instructions.project) {
54
+ parts.push(`\n<project_instructions>\n${instructions.project}\n</project_instructions>`)
55
+ }
56
+
57
+ const skills = Array.isArray(instructions.skills)
58
+ ? instructions.skills
59
+ : [
60
+ ...(Array.isArray(instructions.globalSkills) ? instructions.globalSkills : []),
61
+ ...(Array.isArray(instructions.projectSkills) ? instructions.projectSkills : []),
62
+ ]
63
+
64
+ appendSkillsCatalog(parts, skills)
65
+
66
+ return parts.join('\n')
67
+ }
@@ -0,0 +1,120 @@
1
+ import { Type } from 'typebox'
2
+ import { loadSelectedGlobalSkills, loadSelectedProjectSkills, mergeSkills } from '../skills.mjs'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Canonical workspace tool definitions.
6
+ // These are the single source of truth for tool metadata (name, label,
7
+ // description, parameters). Both the server agent-manager (which wraps them
8
+ // with execute handlers) and the GET /api/tools endpoint (which returns them
9
+ // as JSON) import from here.
10
+ //
11
+ // When adding a new tool, add its definition here. The agent-manager connects
12
+ // it to a handler, and the frontend can fetch definitions from /api/tools.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export const workspaceTools = [
16
+ {
17
+ name: 'get_project_info',
18
+ label: 'Project info',
19
+ description: 'Get the project directory bound to this chat.',
20
+ parameters: Type.Object({}),
21
+ },
22
+ {
23
+ name: 'list_dir',
24
+ label: 'List directory',
25
+ description: 'List files and folders inside the project bound to this chat. Paths are relative to that project root.',
26
+ parameters: Type.Object({
27
+ path: Type.Optional(Type.String({ description: 'Directory path relative to the workspace root. Defaults to .', default: '.' })),
28
+ }),
29
+ },
30
+ {
31
+ name: 'read_file',
32
+ label: 'Read file',
33
+ description: 'Read a UTF-8 text file inside the project bound to this chat. Use offset and limit for large files.',
34
+ parameters: Type.Object({
35
+ path: Type.String({ description: 'File path relative to the workspace root.' }),
36
+ offset: Type.Optional(Type.Number({ description: '1-based line offset.', default: 1 })),
37
+ limit: Type.Optional(Type.Number({ description: 'Maximum number of lines to return.', default: 200 })),
38
+ }),
39
+ },
40
+ {
41
+ name: 'grep_files',
42
+ label: 'Search files',
43
+ description: 'Search text in the project files bound to this chat. Returns matching file paths and line numbers.',
44
+ parameters: Type.Object({
45
+ query: Type.String({ description: 'Plain text or regular expression to search for.' }),
46
+ path: Type.Optional(Type.String({ description: 'Directory path relative to the workspace root. Defaults to .', default: '.' })),
47
+ regex: Type.Optional(Type.Boolean({ description: 'Treat query as a regular expression.', default: false })),
48
+ caseSensitive: Type.Optional(Type.Boolean({ description: 'Use case-sensitive matching.', default: false })),
49
+ limit: Type.Optional(Type.Number({ description: 'Maximum matches to return.', default: 200 })),
50
+ }),
51
+ },
52
+ {
53
+ name: 'write_file',
54
+ label: 'Write file',
55
+ description: 'Create or overwrite a UTF-8 text file inside the project bound to this chat.',
56
+ parameters: Type.Object({
57
+ path: Type.String({ description: 'File path relative to the workspace root.' }),
58
+ content: Type.String({ description: 'Complete file content to write.' }),
59
+ }),
60
+ executionMode: 'sequential',
61
+ },
62
+ {
63
+ name: 'edit_file',
64
+ label: 'Edit file',
65
+ description: 'Edit a text file in the project bound to this chat by replacing exact text. oldText must match exactly once.',
66
+ parameters: Type.Object({
67
+ path: Type.String({ description: 'File path relative to the workspace root.' }),
68
+ oldText: Type.String({ description: 'Exact existing text to replace. Must be unique in the file.' }),
69
+ newText: Type.String({ description: 'Replacement text.' }),
70
+ }),
71
+ executionMode: 'sequential',
72
+ },
73
+ {
74
+ name: 'run_command',
75
+ label: 'Run command',
76
+ description: 'Run a shell command in the project bound to this chat. Use this for lint, build, tests, git status, and diagnostics.',
77
+ parameters: Type.Object({
78
+ command: Type.String({ description: 'Command to execute in the workspace.' }),
79
+ timeoutSeconds: Type.Optional(Type.Number({ description: 'Timeout in seconds. Defaults to 60.', default: 60 })),
80
+ }),
81
+ executionMode: 'sequential',
82
+ },
83
+ ]
84
+
85
+ function activeSkillSchema(skills) {
86
+ const names = skills.map((skill) => skill.name).filter(Boolean)
87
+ return names.length ? Type.String({ enum: names }) : Type.String()
88
+ }
89
+
90
+ export async function createSkillTools(config = {}) {
91
+ const globalSkills = await loadSelectedGlobalSkills(config.globalSkillNames)
92
+ const projectSkills = config.workspaceRoot
93
+ ? await loadSelectedProjectSkills(config.projectSkillNames, config.workspaceRoot)
94
+ : []
95
+ const skills = mergeSkills(globalSkills, projectSkills)
96
+ if (skills.length === 0) return []
97
+
98
+ const skillNameSchema = activeSkillSchema(skills)
99
+ return [
100
+ {
101
+ name: 'activate_skill',
102
+ label: 'Activate skill',
103
+ description: 'Load the full instructions for an enabled Agent Skill when the current task matches its description.',
104
+ parameters: Type.Object({
105
+ name: skillNameSchema,
106
+ }),
107
+ },
108
+ {
109
+ name: 'read_skill_resource',
110
+ label: 'Read skill resource',
111
+ description: 'Read a text resource bundled with an activated Agent Skill. Paths are relative to that skill directory.',
112
+ parameters: Type.Object({
113
+ skill: skillNameSchema,
114
+ path: Type.String({ description: 'Relative path inside the skill directory, for example references/REFERENCE.md or scripts/helper.py.' }),
115
+ offset: Type.Optional(Type.Number({ description: '1-based line offset.', default: 1 })),
116
+ limit: Type.Optional(Type.Number({ description: 'Maximum number of lines to return.', default: 200 })),
117
+ }),
118
+ },
119
+ ]
120
+ }
@@ -1,10 +1,36 @@
1
1
  import { promises as fs } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { spawn } from 'node:child_process'
4
- import { resolveWorkspacePath, toWorkspaceRelative, assertSafeWorkspacePath, truncateText, splitLines, shouldSkipSearchDir, shouldSearchFile, isSensitiveWorkspacePath } from '../utils/workspace.mjs'
4
+ import { resolveWorkspacePath, toWorkspaceRelative, assertSafeWorkspacePath, truncateText, splitLines, walkFiles } from '../utils/workspace.mjs'
5
5
  import { readProjectConfig, getActiveProject } from '../project-config.mjs'
6
+ import {
7
+ formatSkillActivation,
8
+ loadSelectedGlobalSkills,
9
+ loadSelectedProjectSkills,
10
+ mergeSkills,
11
+ readSkillResource,
12
+ } from '../skills.mjs'
6
13
  import { getWorkspaceRoot, getToolWorkspaceRoot } from '../utils/workspace.mjs'
7
14
 
15
+ // --- get_project_info ---
16
+ export async function toolGetProjectInfo(_params, context) {
17
+ const config = context?.project ? null : await readProjectConfig()
18
+ const project = context?.project || getActiveProject(config)
19
+ const workspaceRoot = context?.workspaceRoot || project?.path || getWorkspaceRoot()
20
+
21
+ if (!project) {
22
+ return {
23
+ content: 'No active project is configured.',
24
+ details: { project: null, workspaceRoot },
25
+ }
26
+ }
27
+
28
+ return {
29
+ content: [`Project: ${project.name}`, `Path: ${workspaceRoot}`, `ID: ${project.id}`].join('\n'),
30
+ details: { project, workspaceRoot },
31
+ }
32
+ }
33
+
8
34
  // --- list_dir ---
9
35
  export async function toolListDir(params, context) {
10
36
  const dir = resolveWorkspacePath(params?.path || '.', context)
@@ -54,6 +80,31 @@ export async function toolReadFile(params, context) {
54
80
  }
55
81
 
56
82
  // --- grep_files ---
83
+
84
+ /**
85
+ * Process items with bounded concurrency. Returns results in input order.
86
+ * @template T, R
87
+ * @param {T[]} items
88
+ * @param {(item: T, index: number) => Promise<R>} fn
89
+ * @param {number} concurrency
90
+ * @returns {Promise<R[]>}
91
+ */
92
+ async function poolMap(items, fn, concurrency = 20) {
93
+ const results = new Array(items.length)
94
+ let cursor = 0
95
+
96
+ async function worker() {
97
+ while (cursor < items.length) {
98
+ const index = cursor++
99
+ results[index] = await fn(items[index], index)
100
+ }
101
+ }
102
+
103
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker())
104
+ await Promise.all(workers)
105
+ return results
106
+ }
107
+
57
108
  export async function toolGrepFiles(params, context) {
58
109
  const root = resolveWorkspacePath(params?.path || '.', context)
59
110
  assertSafeWorkspacePath(root, context)
@@ -67,24 +118,62 @@ export async function toolGrepFiles(params, context) {
67
118
 
68
119
  const limit = Math.min(1000, Math.max(1, Number(params?.limit || 200)))
69
120
  const flags = params?.caseSensitive ? 'g' : 'gi'
70
- const matcher = params?.regex
71
- ? new RegExp(query, flags)
72
- : new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags)
121
+ let matcher
122
+ try {
123
+ matcher = params?.regex
124
+ ? new RegExp(query, flags)
125
+ : new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), flags)
126
+ } catch {
127
+ const error = new Error('Invalid regular expression')
128
+ error.statusCode = 400
129
+ throw error
130
+ }
73
131
 
74
132
  const files = await walkFiles(root, [], context)
75
133
  const matches = []
76
134
 
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]}`)
135
+ // Stat and filter files in parallel, then grep in parallel batches
136
+ const candidateResults = await poolMap(files, async (file) => {
137
+ try {
138
+ const stat = await fs.stat(file)
139
+ if (stat.size > 1024 * 1024) return { file, skip: true }
140
+ return { file, skip: false }
141
+ } catch {
142
+ return { file, skip: true }
143
+ }
144
+ })
145
+
146
+ const candidates = candidateResults.filter((r) => !r.skip).map((r) => r.file)
147
+
148
+ // Grep with bounded concurrency — short-circuit when limit reached
149
+ let matchCount = 0
150
+ for (let batchStart = 0; batchStart < candidates.length && matchCount < limit; batchStart += 20) {
151
+ const batch = candidates.slice(batchStart, batchStart + 20)
152
+ const batchMatches = await Promise.all(
153
+ batch.map(async (file) => {
154
+ if (matchCount >= limit) return []
155
+ try {
156
+ const text = await fs.readFile(file, 'utf8')
157
+ const lines = splitLines(text)
158
+ const fileMatches = []
159
+ for (let index = 0; index < lines.length && (matchCount + fileMatches.length) < limit; index++) {
160
+ matcher.lastIndex = 0
161
+ if (matcher.test(lines[index])) {
162
+ fileMatches.push(`${toWorkspaceRelative(file, context)}:${index + 1}: ${lines[index]}`)
163
+ }
164
+ }
165
+ return fileMatches
166
+ } catch {
167
+ return []
168
+ }
169
+ }),
170
+ )
171
+ for (const fm of batchMatches) {
172
+ if (matchCount >= limit) break
173
+ for (const m of fm) {
174
+ if (matchCount >= limit) break
175
+ matches.push(m)
176
+ matchCount++
88
177
  }
89
178
  }
90
179
  }
@@ -144,6 +233,59 @@ export async function toolEditFile(params, context) {
144
233
  }
145
234
  }
146
235
 
236
+ // --- run_command ---
237
+ function activeSkillsForContext(context) {
238
+ return mergeSkills(context?.globalSkills, context?.projectSkills)
239
+ }
240
+
241
+ function activeSkillByName(context, name) {
242
+ const skillName = String(name || '')
243
+ return activeSkillsForContext(context).find((skill) => skill.name === skillName)
244
+ }
245
+
246
+ export async function loadSkillToolContext(config = {}) {
247
+ const globalSkills = await loadSelectedGlobalSkills(config.globalSkillNames)
248
+ const projectSkills = config.workspaceRoot
249
+ ? await loadSelectedProjectSkills(config.projectSkillNames, config.workspaceRoot)
250
+ : []
251
+ return { globalSkills, projectSkills }
252
+ }
253
+
254
+ // --- activate_skill ---
255
+ export async function toolActivateSkill(params, context) {
256
+ const skill = activeSkillByName(context, params?.name)
257
+ if (!skill) {
258
+ const error = new Error(`Unknown or disabled skill: ${params?.name || ''}`)
259
+ error.statusCode = 404
260
+ throw error
261
+ }
262
+
263
+ return {
264
+ content: truncateText(await formatSkillActivation(skill)),
265
+ details: {
266
+ skill: skill.name,
267
+ source: skill.source,
268
+ directory: skill.rootDir,
269
+ },
270
+ }
271
+ }
272
+
273
+ // --- read_skill_resource ---
274
+ export async function toolReadSkillResource(params, context) {
275
+ const skill = activeSkillByName(context, params?.skill)
276
+ if (!skill) {
277
+ const error = new Error(`Unknown or disabled skill: ${params?.skill || ''}`)
278
+ error.statusCode = 404
279
+ throw error
280
+ }
281
+
282
+ const result = await readSkillResource(skill, params?.path, params)
283
+ return {
284
+ content: truncateText(result.content),
285
+ details: result.details,
286
+ }
287
+ }
288
+
147
289
  // --- run_command ---
148
290
  export async function toolRunCommand(params, context) {
149
291
  const command = String(params?.command || '')
@@ -191,40 +333,17 @@ export async function toolRunCommand(params, context) {
191
333
  ].join('\n')
192
334
  resolve({ content: truncateText(content), details: { command, project: context?.project, cwd: getToolWorkspaceRoot(context), code, signal, timedOut } })
193
335
  })
336
+ child.on('error', (err) => {
337
+ clearTimeout(timer)
338
+ resolve({
339
+ isError: true,
340
+ content: truncateText(`Error running command: ${err.message}`),
341
+ details: { command, project: context?.project, error: err.message },
342
+ })
343
+ })
194
344
  })
195
345
  }
196
346
 
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
347
  export const toolHandlers = {
229
348
  get_project_info: toolGetProjectInfo,
230
349
  list_dir: toolListDir,
@@ -233,4 +352,6 @@ export const toolHandlers = {
233
352
  write_file: toolWriteFile,
234
353
  edit_file: toolEditFile,
235
354
  run_command: toolRunCommand,
355
+ activate_skill: toolActivateSkill,
356
+ read_skill_resource: toolReadSkillResource,
236
357
  }
@@ -0,0 +1,34 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { logsDir } from '../storage.mjs'
4
+
5
+ function timestamp() {
6
+ return new Date().toISOString()
7
+ }
8
+
9
+ function logFile() {
10
+ const date = new Date().toISOString().slice(0, 10)
11
+ return path.join(logsDir, `server-${date}.log`)
12
+ }
13
+
14
+ function formatArgs(args) {
15
+ return args.map((a) =>
16
+ typeof a === 'string' ? a : a instanceof Error ? a.stack : JSON.stringify(a),
17
+ ).join(' ')
18
+ }
19
+
20
+ function writeLog(level, ...args) {
21
+ const line = `${timestamp()} [${level}] ${formatArgs(args)}\n`
22
+ process.stderr.write(line)
23
+ try {
24
+ fs.appendFileSync(logFile(), line)
25
+ } catch {
26
+ // ignore write errors
27
+ }
28
+ }
29
+
30
+ export const logger = {
31
+ info: (...args) => writeLog('INFO', ...args),
32
+ warn: (...args) => writeLog('WARN', ...args),
33
+ error: (...args) => writeLog('ERROR', ...args),
34
+ }
@@ -0,0 +1,38 @@
1
+ import os from 'node:os'
2
+
3
+ export function isPrivateIpv4(hostname) {
4
+ if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname || '')) return false
5
+ const parts = hostname.split('.').map((part) => Number(part))
6
+ if (parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false
7
+ const [a, b] = parts
8
+ return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
9
+ }
10
+
11
+ export function isLoopbackAddress(address) {
12
+ if (!address) return false
13
+ const normalized = address.replace(/^::ffff:/, '')
14
+ return normalized === '127.0.0.1' || normalized === '::1' || normalized === 'localhost'
15
+ }
16
+
17
+ export function getLanIpv4Addresses() {
18
+ const result = []
19
+ const seen = new Set()
20
+ const interfaces = os.networkInterfaces()
21
+
22
+ for (const entries of Object.values(interfaces)) {
23
+ for (const entry of entries || []) {
24
+ if (entry.family !== 'IPv4' || entry.internal) continue
25
+ if (!isPrivateIpv4(entry.address)) continue
26
+ if (seen.has(entry.address)) continue
27
+ seen.add(entry.address)
28
+ result.push(entry.address)
29
+ }
30
+ }
31
+
32
+ return result
33
+ }
34
+
35
+ export function getLanUrls(port, protocol = 'http') {
36
+ const safePort = Number(port)
37
+ return getLanIpv4Addresses().map((address) => `${protocol}://${address}${safePort ? `:${safePort}` : ''}`)
38
+ }
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process'
2
+ import { promises as fs } from 'node:fs'
2
3
  import path from 'node:path'
3
4
  import os from 'node:os'
4
5
 
@@ -121,8 +122,37 @@ try {
121
122
  throw error
122
123
  }
123
124
 
125
+ export async function openPathInFileManager(targetPath) {
126
+ const resolved = path.resolve(String(targetPath || ''))
127
+ const stat = await fs.stat(resolved).catch(() => null)
128
+ if (!stat || !stat.isDirectory()) {
129
+ const error = new Error(`Directory does not exist: ${resolved}`)
130
+ error.statusCode = 400
131
+ throw error
132
+ }
133
+
134
+ const command = process.platform === 'win32' ? 'explorer.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open'
135
+ const args = [resolved]
136
+ await new Promise((resolve, reject) => {
137
+ const child = spawn(command, args, {
138
+ detached: true,
139
+ stdio: 'ignore',
140
+ windowsHide: false,
141
+ shell: false,
142
+ })
143
+ child.once('error', (error) => {
144
+ error.statusCode = 500
145
+ reject(error)
146
+ })
147
+ child.once('spawn', () => {
148
+ child.unref()
149
+ resolve()
150
+ })
151
+ })
152
+ }
153
+
124
154
  export function openBrowser(url) {
125
- if (process.env.QUICKFORGE_NO_OPEN === '1' || process.env.FASTCODE_NO_OPEN === '1') return
155
+ if (process.env.QUICKFORGE_NO_OPEN === '1') return
126
156
 
127
157
  const command = process.platform === 'win32' ? 'cmd' : process.platform === 'darwin' ? 'open' : 'xdg-open'
128
158
  const args = process.platform === 'win32' ? ['/c', 'start', '""', url] : [url]
@@ -1,4 +1,4 @@
1
- const DEFAULT_MAX_BODY_BYTES = Number(process.env.QUICKFORGE_MAX_BODY_BYTES || process.env.FASTCODE_MAX_BODY_BYTES || 50 * 1024 * 1024)
1
+ const DEFAULT_MAX_BODY_BYTES = Number(process.env.QUICKFORGE_MAX_BODY_BYTES || 50 * 1024 * 1024)
2
2
 
3
3
  export function sendJson(res, status, value) {
4
4
  const body = JSON.stringify(value)
@@ -27,7 +27,14 @@ export async function readJsonBody(req, maxBodyBytes = DEFAULT_MAX_BODY_BYTES) {
27
27
  chunks.push(chunk)
28
28
  }
29
29
  const text = Buffer.concat(chunks).toString('utf8')
30
- return text ? JSON.parse(text) : null
30
+ if (!text) return null
31
+ try {
32
+ return JSON.parse(text.trimStart())
33
+ } catch {
34
+ const error = new Error('Invalid JSON request body')
35
+ error.statusCode = 400
36
+ throw error
37
+ }
31
38
  }
32
39
 
33
40
  export function decodeSegment(value) {