@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.
- package/README.md +22 -16
- package/bin/quickforge.mjs +83 -8
- package/dist/assets/{anthropic-u1nbNXhV.js → anthropic-DLvtwHL2.js} +2 -2
- package/dist/assets/{azure-openai-responses-DQ6xSOmb.js → azure-openai-responses-D68z7hLN.js} +1 -1
- package/dist/assets/css-utils-rkE68RDy.js +1 -0
- package/dist/assets/{google-OeyKMN12.js → google-B_sSaRBM.js} +1 -1
- package/dist/assets/{google-gemini-cli-SnPixyBu.js → google-gemini-cli-CYqGXjGi.js} +1 -1
- package/dist/assets/google-shared-XhYUKiGZ.js +11 -0
- package/dist/assets/{google-vertex-y0o2eCZV.js → google-vertex-DSMuB4YB.js} +1 -1
- package/dist/assets/icons-BsZ9PlYY.js +1 -0
- package/dist/assets/index-BqFfVQJM.css +3 -0
- package/dist/assets/{index-CK_34smc.js → index-DoraECXN.js} +801 -662
- package/dist/assets/lit-vendor-1dsGB-Iy.js +2 -0
- package/dist/assets/{mistral-DzE_jn-B.js → mistral-BZngRB4x.js} +2 -2
- package/dist/assets/{openai-codex-responses-MtFRvp_b.js → openai-codex-responses-Niu7xDYK.js} +1 -1
- package/dist/assets/openai-completions-B2bhb9k0.js +5 -0
- package/dist/assets/{openai-responses-C4n0VhzY.js → openai-responses-CDYDv8yL.js} +1 -1
- package/dist/assets/{openai-responses-shared-D2RkRvTj.js → openai-responses-shared-BIKPTpEQ.js} +1 -1
- package/dist/assets/react-vendor-Ds3ovY0w.js +9 -0
- package/dist/assets/rolldown-runtime-CkqCuyE9.js +1 -0
- package/dist/index.html +7 -3
- package/package.json +2 -1
- package/server/agent-manager.mjs +1053 -0
- package/server/conversation-compaction.mjs +302 -0
- package/server/custom-commands.mjs +344 -0
- package/server/index.mjs +326 -34
- package/server/project-config.mjs +85 -55
- package/server/reasoning-cache.mjs +51 -0
- package/server/restart-supervisor.mjs +38 -0
- package/server/routes/agent.mjs +323 -0
- package/server/routes/backup.mjs +250 -0
- package/server/routes/instructions.mjs +6 -17
- package/server/routes/project.mjs +49 -19
- package/server/routes/scheduled-tasks.mjs +424 -0
- package/server/routes/shared-conversation.mjs +404 -0
- package/server/routes/shares.mjs +84 -0
- package/server/routes/skills.mjs +145 -0
- package/server/routes/static.mjs +4 -3
- package/server/routes/storage.mjs +66 -12
- package/server/routes/system.mjs +35 -0
- package/server/routes/tools.mjs +53 -2
- package/server/session-utils.mjs +102 -0
- package/server/share-store.mjs +468 -0
- package/server/skills.mjs +539 -0
- package/server/storage.mjs +578 -133
- package/server/system-prompt.mjs +67 -0
- package/server/tools/definitions.mjs +120 -0
- package/server/tools/index.mjs +167 -46
- package/server/utils/logger.mjs +34 -0
- package/server/utils/network.mjs +38 -0
- package/server/utils/platform.mjs +31 -1
- package/server/utils/response.mjs +9 -2
- package/skills/ai-context-package/SKILL.md +104 -0
- package/skills/ai-context-package/skill.json +9 -0
- package/skills/code-review/SKILL.md +23 -0
- package/skills/code-review/skill.json +9 -0
- package/skills/frontend-react/SKILL.md +22 -0
- package/skills/frontend-react/skill.json +9 -0
- package/skills/quickforge-project/SKILL.md +22 -0
- package/skills/quickforge-project/skill.json +9 -0
- package/dist/assets/chunk-62oNxeRG.js +0 -1
- package/dist/assets/confirm-dialog-DSmrqQ60.js +0 -1
- package/dist/assets/google-shared-CXUHW-9O.js +0 -11
- package/dist/assets/index-BQJ8qi1U.css +0 -3
- package/dist/assets/openai-completions-C2dhwzO8.js +0 -5
- package/dist/assets/prompt-dialog-B4BD09Oc.js +0 -1
- /package/dist/assets/{github-copilot-headers-C0toI16e.js → github-copilot-headers-CrI0CIJ7.js} +0 -0
- /package/dist/assets/{hash-fDQBJsbb.js → hash-Bt1aVMQ3.js} +0 -0
- /package/dist/assets/{headers-Drkm68SQ.js → headers-5EYI0_pl.js} +0 -0
- /package/dist/assets/{openai-CuiHR4mv.js → openai-Cn7eGqwa.js} +0 -0
- /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, '<')
|
|
17
|
+
.replace(/>/g, '>')
|
|
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
|
+
}
|
package/server/tools/index.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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'
|
|
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 ||
|
|
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
|
-
|
|
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) {
|