@shawnstack/quickforge 1.1.0 → 1.2.1

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 (72) hide show
  1. package/README.md +1 -1
  2. package/bin/quickforge.mjs +72 -7
  3. package/dist/assets/{anthropic-By-wpU1w.js → anthropic-DLvtwHL2.js} +2 -2
  4. package/dist/assets/{azure-openai-responses-C8spS__i.js → azure-openai-responses-D68z7hLN.js} +1 -1
  5. package/dist/assets/css-utils-rkE68RDy.js +1 -0
  6. package/dist/assets/{google-DiIcyajo.js → google-B_sSaRBM.js} +1 -1
  7. package/dist/assets/{google-gemini-cli-BXZFGMXD.js → google-gemini-cli-CYqGXjGi.js} +1 -1
  8. package/dist/assets/google-shared-XhYUKiGZ.js +11 -0
  9. package/dist/assets/{google-vertex-D93MV5Cx.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-DoraECXN.js +3187 -0
  13. package/dist/assets/lit-vendor-1dsGB-Iy.js +2 -0
  14. package/dist/assets/{mistral-BAJNGYqd.js → mistral-BZngRB4x.js} +2 -2
  15. package/dist/assets/{openai-codex-responses-BHHCy65K.js → openai-codex-responses-Niu7xDYK.js} +1 -1
  16. package/dist/assets/openai-completions-B2bhb9k0.js +5 -0
  17. package/dist/assets/{openai-responses-CP9-AyAD.js → openai-responses-CDYDv8yL.js} +1 -1
  18. package/dist/assets/{openai-responses-shared-_z7sua8J.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 +14 -13
  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 +322 -32
  27. package/server/project-config.mjs +80 -31
  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 +46 -10
  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 +58 -10
  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 +247 -6
  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 +30 -0
  52. package/server/utils/response.mjs +8 -1
  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-4mZt9XEq.js +0 -1
  63. package/dist/assets/google-shared-CXUHW-9O.js +0 -11
  64. package/dist/assets/index-Bq6VHkyY.js +0 -3048
  65. package/dist/assets/index-D7uXa1RT.css +0 -3
  66. package/dist/assets/openai-completions-BtZAvOiJ.js +0 -5
  67. package/dist/assets/prompt-dialog-BGMKszUz.js +0 -1
  68. /package/dist/assets/{github-copilot-headers-C0toI16e.js → github-copilot-headers-CrI0CIJ7.js} +0 -0
  69. /package/dist/assets/{hash-fDQBJsbb.js → hash-Bt1aVMQ3.js} +0 -0
  70. /package/dist/assets/{headers-Drkm68SQ.js → headers-5EYI0_pl.js} +0 -0
  71. /package/dist/assets/{openai-CuiHR4mv.js → openai-Cn7eGqwa.js} +0 -0
  72. /package/dist/assets/{transform-messages-BFwlToJ0.js → transform-messages-CV4kCtBB.js} +0 -0
@@ -0,0 +1,539 @@
1
+ import { existsSync, promises as fs } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { dataDir } from './storage.mjs'
5
+
6
+ const userSkillsDir = path.join(dataDir, 'skills')
7
+ const sharedUserSkillsDir = path.join(os.homedir(), '.agents', 'skills')
8
+ const defaultEntry = 'SKILL.md'
9
+ const resourceDirs = ['scripts', 'references', 'assets']
10
+ const maxResourceFiles = 200
11
+
12
+ function isValidSkillName(value) {
13
+ return (
14
+ typeof value === 'string' &&
15
+ value.length >= 1 &&
16
+ value.length <= 64 &&
17
+ /^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value)
18
+ )
19
+ }
20
+
21
+ function normalizeString(value) {
22
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
23
+ }
24
+
25
+ function normalizeStringArray(value) {
26
+ if (!Array.isArray(value)) return []
27
+ const result = []
28
+ const seen = new Set()
29
+ for (const item of value) {
30
+ const text = normalizeString(item)
31
+ if (!text || seen.has(text)) continue
32
+ seen.add(text)
33
+ result.push(text)
34
+ }
35
+ return result
36
+ }
37
+
38
+ export function normalizeSkillNames(value) {
39
+ return normalizeStringArray(value).filter(isValidSkillName)
40
+ }
41
+
42
+ function isPathInside(root, target) {
43
+ const relative = path.relative(root, target)
44
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
45
+ }
46
+
47
+ async function readJsonFile(file, defaultValue = {}) {
48
+ try {
49
+ const text = await fs.readFile(file, 'utf8')
50
+ const json = text.trimStart()
51
+ return json ? JSON.parse(json) : defaultValue
52
+ } catch (error) {
53
+ if (error?.code === 'ENOENT') return defaultValue
54
+ throw error
55
+ }
56
+ }
57
+
58
+ async function readOptionalText(file) {
59
+ try {
60
+ return await fs.readFile(file, 'utf8')
61
+ } catch (error) {
62
+ if (error?.code === 'ENOENT') return null
63
+ throw error
64
+ }
65
+ }
66
+
67
+ function parseFrontmatter(text) {
68
+ const normalized = String(text || '').replace(/^\uFEFF/, '')
69
+ const match = normalized.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)([\s\S]*)$/)
70
+ if (!match) return null
71
+ return {
72
+ frontmatter: match[1],
73
+ body: match[2].trim(),
74
+ }
75
+ }
76
+
77
+ function leadingIndent(line) {
78
+ const match = line.match(/^\s*/)
79
+ return match ? match[0].length : 0
80
+ }
81
+
82
+ function stripInlineComment(value) {
83
+ const trimmed = value.trim()
84
+ if (trimmed.startsWith('"') || trimmed.startsWith("'")) return trimmed
85
+ const index = trimmed.indexOf(' #')
86
+ return index >= 0 ? trimmed.slice(0, index).trimEnd() : trimmed
87
+ }
88
+
89
+ function parseYamlScalar(value) {
90
+ const trimmed = stripInlineComment(String(value ?? ''))
91
+ if (!trimmed) return ''
92
+
93
+ if (
94
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
95
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
96
+ ) {
97
+ return trimmed
98
+ .slice(1, -1)
99
+ .replace(/\\"/g, '"')
100
+ .replace(/\\'/g, "'")
101
+ }
102
+
103
+ return trimmed
104
+ }
105
+
106
+ function collectIndentedBlock(lines, startIndex, parentIndent) {
107
+ const block = []
108
+ let index = startIndex
109
+ while (index < lines.length) {
110
+ const line = lines[index]
111
+ if (!line.trim()) {
112
+ block.push(line)
113
+ index++
114
+ continue
115
+ }
116
+ if (leadingIndent(line) <= parentIndent) break
117
+ block.push(line)
118
+ index++
119
+ }
120
+ return { block, nextIndex: index }
121
+ }
122
+
123
+ function parseBlockScalar(lines, style) {
124
+ const nonEmpty = lines.filter((line) => line.trim())
125
+ const minIndent = nonEmpty.length
126
+ ? Math.min(...nonEmpty.map((line) => leadingIndent(line)))
127
+ : 0
128
+ const unindented = lines.map((line) => line.slice(Math.min(minIndent, line.length)))
129
+ return style === '>'
130
+ ? unindented.join(' ').replace(/\s+/g, ' ').trim()
131
+ : unindented.join('\n').trim()
132
+ }
133
+
134
+ function parseSimpleYamlMap(text) {
135
+ const result = {}
136
+ const lines = String(text || '').split(/\r?\n/)
137
+ let index = 0
138
+
139
+ while (index < lines.length) {
140
+ const line = lines[index]
141
+ const trimmed = line.trim()
142
+ if (!trimmed || trimmed.startsWith('#') || leadingIndent(line) > 0) {
143
+ index++
144
+ continue
145
+ }
146
+
147
+ const match = line.match(/^([A-Za-z0-9_-]+):(?:\s*(.*))?$/)
148
+ if (!match) {
149
+ index++
150
+ continue
151
+ }
152
+
153
+ const [, key, rawValue = ''] = match
154
+ const value = rawValue.trim()
155
+
156
+ if (value === '|' || value === '>') {
157
+ const { block, nextIndex } = collectIndentedBlock(lines, index + 1, 0)
158
+ result[key] = parseBlockScalar(block, value)
159
+ index = nextIndex
160
+ continue
161
+ }
162
+
163
+ if (value) {
164
+ result[key] = parseYamlScalar(value)
165
+ index++
166
+ continue
167
+ }
168
+
169
+ const nested = {}
170
+ let nestedIndex = index + 1
171
+ while (nestedIndex < lines.length) {
172
+ const nestedLine = lines[nestedIndex]
173
+ const nestedTrimmed = nestedLine.trim()
174
+ if (!nestedTrimmed || nestedTrimmed.startsWith('#')) {
175
+ nestedIndex++
176
+ continue
177
+ }
178
+
179
+ const indent = leadingIndent(nestedLine)
180
+ if (indent <= 0) break
181
+
182
+ const nestedMatch = nestedLine.slice(indent).match(/^([A-Za-z0-9_.-]+):(?:\s*(.*))?$/)
183
+ if (!nestedMatch) {
184
+ nestedIndex++
185
+ continue
186
+ }
187
+
188
+ const [, nestedKey, nestedRawValue = ''] = nestedMatch
189
+ nested[nestedKey] = parseYamlScalar(nestedRawValue.trim())
190
+ nestedIndex++
191
+ }
192
+
193
+ result[key] = Object.keys(nested).length ? nested : ''
194
+ index = Object.keys(nested).length ? nestedIndex : index + 1
195
+ }
196
+
197
+ return result
198
+ }
199
+
200
+ function normalizeMetadata(value) {
201
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
202
+ const metadata = {}
203
+ for (const [key, item] of Object.entries(value)) {
204
+ if (!key || item === undefined || item === null || typeof item === 'object') continue
205
+ metadata[key] = String(item)
206
+ }
207
+ return metadata
208
+ }
209
+
210
+ async function listSkillDirectories(root) {
211
+ if (!root || !existsSync(root)) return []
212
+ const entries = await fs.readdir(root, { withFileTypes: true })
213
+ return entries
214
+ .filter((entry) => entry.isDirectory())
215
+ .map((entry) => path.join(root, entry.name))
216
+ }
217
+
218
+ function splitMetadataList(value) {
219
+ return String(value || '')
220
+ .split(',')
221
+ .map((item) => item.trim())
222
+ .filter(Boolean)
223
+ }
224
+
225
+ function metadataValue(metadata, key) {
226
+ return normalizeString(metadata[key])
227
+ }
228
+
229
+ function skillFromStandardMarkdown(rootDir, source, text) {
230
+ const parsed = parseFrontmatter(text)
231
+ if (!parsed?.body) return null
232
+
233
+ const rawManifest = parseSimpleYamlMap(parsed.frontmatter)
234
+ const name = normalizeString(rawManifest.name)
235
+ const description = normalizeString(rawManifest.description)
236
+ if (!name || !isValidSkillName(name)) return null
237
+ if (name !== path.basename(rootDir)) return null
238
+ if (!description || description.length > 1024) return null
239
+
240
+ const metadata = normalizeMetadata(rawManifest.metadata)
241
+ const compatibility = normalizeString(rawManifest.compatibility)
242
+
243
+ return {
244
+ name,
245
+ displayName: metadataValue(metadata, 'displayName') || metadataValue(metadata, 'title'),
246
+ description,
247
+ license: normalizeString(rawManifest.license),
248
+ compatibility: compatibility && compatibility.length <= 500 ? compatibility : undefined,
249
+ metadata,
250
+ allowedTools: normalizeString(rawManifest['allowed-tools']),
251
+ version: metadataValue(metadata, 'version'),
252
+ tags: splitMetadataList(metadata.tags),
253
+ triggers: splitMetadataList(metadata.triggers),
254
+ entry: defaultEntry,
255
+ source,
256
+ rootDir,
257
+ location: path.join(rootDir, defaultEntry),
258
+ instructions: parsed.body,
259
+ }
260
+ }
261
+
262
+ async function loadLegacySkillDirectory(rootDir, source) {
263
+ const rawManifest = await readJsonFile(path.join(rootDir, 'skill.json'), null)
264
+ if (!rawManifest || typeof rawManifest !== 'object') return null
265
+ if (rawManifest.enabled === false) return null
266
+
267
+ const name = normalizeString(rawManifest.name) || path.basename(rootDir)
268
+ if (!isValidSkillName(name)) return null
269
+
270
+ const entry = normalizeString(rawManifest.entry) || defaultEntry
271
+ const entryPath = path.resolve(rootDir, entry)
272
+ if (!isPathInside(rootDir, entryPath)) return null
273
+
274
+ const instructions = (await readOptionalText(entryPath))?.trim()
275
+ const description = normalizeString(rawManifest.description)
276
+ if (!instructions || !description) return null
277
+
278
+ return {
279
+ name,
280
+ displayName: normalizeString(rawManifest.displayName) || normalizeString(rawManifest.title),
281
+ description,
282
+ license: normalizeString(rawManifest.license),
283
+ compatibility: normalizeString(rawManifest.compatibility),
284
+ metadata: rawManifest.metadata && typeof rawManifest.metadata === 'object' ? rawManifest.metadata : {},
285
+ allowedTools: normalizeString(rawManifest.allowedTools) || normalizeString(rawManifest['allowed-tools']),
286
+ version: normalizeString(rawManifest.version),
287
+ tags: normalizeStringArray(rawManifest.tags),
288
+ triggers: normalizeStringArray(rawManifest.triggers),
289
+ entry,
290
+ source,
291
+ rootDir,
292
+ location: entryPath,
293
+ instructions,
294
+ }
295
+ }
296
+
297
+ async function loadSkillDirectory(rootDir, source) {
298
+ const skillText = await readOptionalText(path.join(rootDir, defaultEntry))
299
+ if (skillText) {
300
+ const standardSkill = skillFromStandardMarkdown(rootDir, source, skillText)
301
+ if (standardSkill) return standardSkill
302
+ }
303
+
304
+ return loadLegacySkillDirectory(rootDir, source)
305
+ }
306
+
307
+ function projectClientSkillsDir(workspaceRoot) {
308
+ return workspaceRoot ? path.join(path.resolve(workspaceRoot), '.quickforge', 'skills') : ''
309
+ }
310
+
311
+ function projectSharedSkillsDir(workspaceRoot) {
312
+ return workspaceRoot ? path.join(path.resolve(workspaceRoot), '.agents', 'skills') : ''
313
+ }
314
+
315
+ async function loadSkillsFromSources(sources) {
316
+ const skillsByName = new Map()
317
+
318
+ for (const source of sources) {
319
+ for (const skillDir of await listSkillDirectories(source.dir)) {
320
+ try {
321
+ const skill = await loadSkillDirectory(skillDir, source.name)
322
+ if (!skill) continue
323
+ if (skillsByName.has(skill.name)) skillsByName.delete(skill.name)
324
+ skillsByName.set(skill.name, skill)
325
+ } catch (error) {
326
+ console.warn(`Failed to load skill from ${skillDir}:`, error.message || error)
327
+ }
328
+ }
329
+ }
330
+
331
+ return [...skillsByName.values()].sort((a, b) => {
332
+ const left = (a.displayName || a.name).toLowerCase()
333
+ const right = (b.displayName || b.name).toLowerCase()
334
+ return left.localeCompare(right)
335
+ })
336
+ }
337
+
338
+ function searchDirsForList(value) {
339
+ return value.length === 1 ? value[0] : value.slice()
340
+ }
341
+
342
+ function summarizeSkills(skills) {
343
+ return skills.map(({ instructions: _instructions, rootDir: _rootDir, location: _location, ...summary }) => summary)
344
+ }
345
+
346
+ function filterKnownNames(skillNames, skills) {
347
+ const selected = normalizeSkillNames(skillNames)
348
+ if (selected.length === 0) return []
349
+
350
+ const known = new Set(skills.map((skill) => skill.name))
351
+ return selected.filter((name) => known.has(name))
352
+ }
353
+
354
+ function selectSkills(skillNames, skills) {
355
+ const selected = normalizeSkillNames(skillNames)
356
+ if (selected.length === 0) return []
357
+
358
+ const byName = new Map(skills.map((skill) => [skill.name, skill]))
359
+ return selected.map((name) => byName.get(name)).filter(Boolean)
360
+ }
361
+
362
+ export function mergeSkills(...skillLists) {
363
+ const skillsByName = new Map()
364
+ for (const skillList of skillLists) {
365
+ for (const skill of Array.isArray(skillList) ? skillList : []) {
366
+ if (!skill?.name) continue
367
+ if (skillsByName.has(skill.name)) skillsByName.delete(skill.name)
368
+ skillsByName.set(skill.name, skill)
369
+ }
370
+ }
371
+ return [...skillsByName.values()]
372
+ }
373
+
374
+ export const skillSearchPaths = {
375
+ global: searchDirsForList([sharedUserSkillsDir, userSkillsDir]),
376
+ project: ['<project>/.agents/skills', '<project>/.quickforge/skills'],
377
+ }
378
+
379
+ export function projectSkillSearchPaths(workspaceRoot) {
380
+ if (!workspaceRoot) return skillSearchPaths.project.slice()
381
+ return searchDirsForList([projectSharedSkillsDir(workspaceRoot), projectClientSkillsDir(workspaceRoot)])
382
+ }
383
+
384
+ export async function loadGlobalSkills() {
385
+ return loadSkillsFromSources([
386
+ { dir: sharedUserSkillsDir, name: 'user-shared' },
387
+ { dir: userSkillsDir, name: 'user' },
388
+ ])
389
+ }
390
+
391
+ export async function loadProjectSkills(workspaceRoot) {
392
+ return loadSkillsFromSources([
393
+ { dir: projectSharedSkillsDir(workspaceRoot), name: 'project-shared' },
394
+ { dir: projectClientSkillsDir(workspaceRoot), name: 'project' },
395
+ ])
396
+ }
397
+
398
+ export async function loadSkills() {
399
+ return loadGlobalSkills()
400
+ }
401
+
402
+ export async function listGlobalSkillSummaries() {
403
+ return summarizeSkills(await loadGlobalSkills())
404
+ }
405
+
406
+ export async function listProjectSkillSummaries(workspaceRoot) {
407
+ return summarizeSkills(await loadProjectSkills(workspaceRoot))
408
+ }
409
+
410
+ export async function listSkillSummaries() {
411
+ return listGlobalSkillSummaries()
412
+ }
413
+
414
+ export async function filterKnownGlobalSkillNames(skillNames) {
415
+ return filterKnownNames(skillNames, await loadGlobalSkills())
416
+ }
417
+
418
+ export async function filterKnownProjectSkillNames(skillNames, workspaceRoot) {
419
+ return filterKnownNames(skillNames, await loadProjectSkills(workspaceRoot))
420
+ }
421
+
422
+ export async function filterKnownSkillNames(skillNames) {
423
+ return filterKnownGlobalSkillNames(skillNames)
424
+ }
425
+
426
+ export async function loadSelectedGlobalSkills(skillNames) {
427
+ return selectSkills(skillNames, await loadGlobalSkills())
428
+ }
429
+
430
+ export async function loadSelectedProjectSkills(skillNames, workspaceRoot) {
431
+ return selectSkills(skillNames, await loadProjectSkills(workspaceRoot))
432
+ }
433
+
434
+ export async function loadSelectedSkills(skillNames) {
435
+ return loadSelectedGlobalSkills(skillNames)
436
+ }
437
+
438
+ async function walkResourceFiles(rootDir, currentDir, files, maxFiles) {
439
+ if (files.length >= maxFiles) return
440
+ let entries
441
+ try {
442
+ entries = await fs.readdir(currentDir, { withFileTypes: true })
443
+ } catch {
444
+ return
445
+ }
446
+
447
+ entries.sort((a, b) => a.name.localeCompare(b.name))
448
+ for (const entry of entries) {
449
+ if (files.length >= maxFiles) return
450
+ const fullPath = path.join(currentDir, entry.name)
451
+ if (entry.isDirectory()) {
452
+ await walkResourceFiles(rootDir, fullPath, files, maxFiles)
453
+ } else if (entry.isFile()) {
454
+ files.push(path.relative(rootDir, fullPath).replace(/\\/g, '/'))
455
+ }
456
+ }
457
+ }
458
+
459
+ export async function listSkillResourceFiles(skill, maxFiles = maxResourceFiles) {
460
+ const rootDir = skill?.rootDir
461
+ if (!rootDir) return []
462
+
463
+ const files = []
464
+ for (const dirName of resourceDirs) {
465
+ await walkResourceFiles(rootDir, path.join(rootDir, dirName), files, maxFiles)
466
+ if (files.length >= maxFiles) break
467
+ }
468
+ return files
469
+ }
470
+
471
+ function escapeAttribute(value) {
472
+ return String(value ?? '')
473
+ .replace(/&/g, '&amp;')
474
+ .replace(/"/g, '&quot;')
475
+ .replace(/</g, '&lt;')
476
+ .replace(/>/g, '&gt;')
477
+ }
478
+
479
+ export async function formatSkillActivation(skill) {
480
+ const resources = await listSkillResourceFiles(skill)
481
+ const resourceBlock = resources.length
482
+ ? `\n\n<skill_resources>\n${resources.map((file) => ` <file>${file}</file>`).join('\n')}\n</skill_resources>`
483
+ : ''
484
+
485
+ return `<skill_content name="${escapeAttribute(skill.name)}">\n${skill.instructions}\n\nSkill directory: ${skill.rootDir}\nRelative paths in this skill are relative to the skill directory. Use read_skill_resource with the skill name and a relative path when you need a bundled reference, script, or asset.${resourceBlock}\n</skill_content>`
486
+ }
487
+
488
+ export async function readSkillResource(skill, resourcePath, options = {}) {
489
+ const rootDir = skill?.rootDir
490
+ const input = normalizeString(resourcePath)
491
+ if (!rootDir || !input) {
492
+ const error = new Error('skill name and resource path are required')
493
+ error.statusCode = 400
494
+ throw error
495
+ }
496
+ if (path.isAbsolute(input)) {
497
+ const error = new Error('resource path must be relative to the skill directory')
498
+ error.statusCode = 400
499
+ throw error
500
+ }
501
+
502
+ const file = path.resolve(rootDir, input)
503
+ if (!isPathInside(rootDir, file)) {
504
+ const error = new Error(`resource path is outside the skill directory: ${input}`)
505
+ error.statusCode = 403
506
+ throw error
507
+ }
508
+
509
+ const stat = await fs.stat(file).catch(() => null)
510
+ if (!stat || !stat.isFile()) {
511
+ const error = new Error(`skill resource not found: ${input}`)
512
+ error.statusCode = 404
513
+ throw error
514
+ }
515
+ if (stat.size > 1024 * 1024) {
516
+ const error = new Error(`skill resource is too large to read as text: ${input}`)
517
+ error.statusCode = 413
518
+ throw error
519
+ }
520
+
521
+ const text = await fs.readFile(file, 'utf8')
522
+ const lines = text.split(/\r?\n/)
523
+ const offset = Math.max(1, Number(options.offset || 1))
524
+ const limit = Math.min(2000, Math.max(1, Number(options.limit || 200)))
525
+ const selected = lines.slice(offset - 1, offset - 1 + limit)
526
+ const content = selected.map((line, index) => `${offset + index}: ${line}`).join('\n')
527
+ const suffix = offset - 1 + limit < lines.length ? `\n\n[showing ${selected.length} of ${lines.length} lines]` : ''
528
+
529
+ return {
530
+ content: `${content}${suffix}`,
531
+ details: {
532
+ skill: skill.name,
533
+ path: path.relative(rootDir, file).replace(/\\/g, '/'),
534
+ totalLines: lines.length,
535
+ offset,
536
+ limit,
537
+ },
538
+ }
539
+ }