Skip to content

Commit 28e8834

Browse files
rewrite relative links in loaded skills (#123)
* rewrite relative links in loaded skills * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent e972d7a commit 28e8834

2 files changed

Lines changed: 410 additions & 2 deletions

File tree

packages/intent/src/commands/load.ts

Lines changed: 315 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { existsSync, readFileSync } from 'node:fs'
2-
import { isAbsolute, relative, resolve } from 'node:path'
2+
import { dirname, isAbsolute, relative, resolve } from 'node:path'
33
import { fail } from '../cli-error.js'
44
import { scanOptionsFromGlobalFlags } from '../cli-support.js'
55
import { resolveSkillUse } from '../resolver.js'
66
import { parseSkillUse } from '../skill-use.js'
7+
import { toPosixPath } from '../utils.js'
78
import type { GlobalScanFlags } from '../cli-support.js'
89
import type { ScanOptions, ScanResult } from '../types.js'
910

@@ -27,6 +28,314 @@ function isPathInsidePackageRoot(path: string, packageRoot: string): boolean {
2728
)
2829
}
2930

31+
function splitDestinationSuffix(destination: string): {
32+
pathPart: string
33+
suffix: string
34+
} {
35+
const hashIndex = destination.indexOf('#')
36+
const queryIndex = destination.indexOf('?')
37+
const suffixIndex =
38+
hashIndex === -1
39+
? queryIndex
40+
: queryIndex === -1
41+
? hashIndex
42+
: Math.min(hashIndex, queryIndex)
43+
44+
if (suffixIndex === -1) {
45+
return { pathPart: destination, suffix: '' }
46+
}
47+
48+
return {
49+
pathPart: destination.slice(0, suffixIndex),
50+
suffix: destination.slice(suffixIndex),
51+
}
52+
}
53+
54+
function isExternalOrAbsoluteDestination(destination: string): boolean {
55+
return (
56+
destination === '' ||
57+
destination.startsWith('#') ||
58+
destination.startsWith('?') ||
59+
destination.startsWith('//') ||
60+
/^[A-Za-z][A-Za-z0-9+.-]*:/.test(destination) ||
61+
isAbsolute(destination)
62+
)
63+
}
64+
65+
interface MarkdownDestinationRewriteContext {
66+
cwd: string
67+
resolvedPackageRoot: string
68+
skillDir: string
69+
}
70+
71+
function findClosingBracket(line: string, start: number): number {
72+
let depth = 0
73+
74+
for (let index = start; index < line.length; index++) {
75+
const char = line[index]!
76+
if (char === '\\') {
77+
index++
78+
continue
79+
}
80+
if (char === '[') {
81+
depth++
82+
continue
83+
}
84+
if (char === ']') {
85+
depth--
86+
if (depth === 0) return index
87+
}
88+
}
89+
90+
return -1
91+
}
92+
93+
function findClosingParen(line: string, start: number): number {
94+
for (let index = start; index < line.length; index++) {
95+
const char = line[index]!
96+
if (char === '\\') {
97+
index++
98+
continue
99+
}
100+
if (char === ')') return index
101+
}
102+
103+
return -1
104+
}
105+
106+
function readBareDestination(
107+
line: string,
108+
start: number,
109+
): { destinationEnd: number; endParen: number } | null {
110+
let depth = 0
111+
112+
for (let index = start; index < line.length; index++) {
113+
const char = line[index]!
114+
if (char === '\\') {
115+
index++
116+
continue
117+
}
118+
if (char === '(') {
119+
depth++
120+
continue
121+
}
122+
if (char === ')') {
123+
if (depth === 0) {
124+
return { destinationEnd: index, endParen: index }
125+
}
126+
depth--
127+
continue
128+
}
129+
if (/\s/.test(char) && depth === 0) {
130+
const endParen = findClosingParen(line, index)
131+
if (endParen === -1) return null
132+
return { destinationEnd: index, endParen }
133+
}
134+
}
135+
136+
return null
137+
}
138+
139+
function readMarkdownDestination(
140+
line: string,
141+
start: number,
142+
): {
143+
destination: string
144+
destinationStart: number
145+
destinationEnd: number
146+
endParen: number
147+
} | null {
148+
let cursor = start
149+
while (cursor < line.length && /\s/.test(line[cursor]!)) cursor++
150+
151+
if (line[cursor] === '<') {
152+
const destinationStart = cursor + 1
153+
const destinationEnd = line.indexOf('>', destinationStart)
154+
if (destinationEnd === -1) return null
155+
const endParen = findClosingParen(line, destinationEnd + 1)
156+
if (endParen === -1) return null
157+
return {
158+
destination: line.slice(destinationStart, destinationEnd),
159+
destinationStart,
160+
destinationEnd,
161+
endParen,
162+
}
163+
}
164+
165+
const read = readBareDestination(line, cursor)
166+
if (!read) return null
167+
168+
return {
169+
destination: line.slice(cursor, read.destinationEnd),
170+
destinationStart: cursor,
171+
destinationEnd: read.destinationEnd,
172+
endParen: read.endParen,
173+
}
174+
}
175+
176+
function getCodeFenceMarker(line: string): '`' | '~' | null {
177+
const match = line.match(/^\s*(`{3,}|~{3,})/)
178+
const marker = match?.[1]?.[0]
179+
return marker === '`' || marker === '~' ? marker : null
180+
}
181+
182+
function rewriteMarkdownDestination({
183+
context,
184+
destination,
185+
}: {
186+
context: MarkdownDestinationRewriteContext
187+
destination: string
188+
}): string {
189+
if (isExternalOrAbsoluteDestination(destination)) return destination
190+
191+
const { pathPart, suffix } = splitDestinationSuffix(destination)
192+
if (isExternalOrAbsoluteDestination(pathPart)) return destination
193+
194+
const resolvedDestinationPath = resolve(context.skillDir, pathPart)
195+
const relativeToPackageRoot = relative(
196+
context.resolvedPackageRoot,
197+
resolvedDestinationPath,
198+
)
199+
if (
200+
relativeToPackageRoot.startsWith('..') ||
201+
isAbsolute(relativeToPackageRoot)
202+
) {
203+
return destination
204+
}
205+
206+
const relativeToCwd = relative(context.cwd, resolvedDestinationPath)
207+
const rewrittenPath =
208+
relativeToCwd &&
209+
!relativeToCwd.startsWith('..') &&
210+
!isAbsolute(relativeToCwd)
211+
? relativeToCwd
212+
: resolvedDestinationPath
213+
214+
return `${toPosixPath(rewrittenPath)}${suffix}`
215+
}
216+
217+
function rewriteMarkdownLineDestinations({
218+
context,
219+
line,
220+
}: {
221+
context: MarkdownDestinationRewriteContext
222+
line: string
223+
}): string {
224+
if (!line.includes('[')) return line
225+
226+
let output = ''
227+
let cursor = 0
228+
229+
while (cursor < line.length) {
230+
const nextCodeStart = line.indexOf('`', cursor)
231+
const nextLinkStart = line.indexOf('[', cursor)
232+
233+
if (nextLinkStart === -1) {
234+
output += line.slice(cursor)
235+
break
236+
}
237+
238+
if (nextCodeStart !== -1 && nextCodeStart < nextLinkStart) {
239+
output += line.slice(cursor, nextCodeStart)
240+
cursor = nextCodeStart
241+
const codeStart = cursor
242+
while (cursor < line.length && line[cursor] === '`') cursor++
243+
const marker = line.slice(codeStart, cursor)
244+
const codeEnd = line.indexOf(marker, cursor)
245+
if (codeEnd === -1) {
246+
output += line.slice(codeStart)
247+
break
248+
}
249+
output += line.slice(codeStart, codeEnd + marker.length)
250+
cursor = codeEnd + marker.length
251+
continue
252+
}
253+
254+
const linkStart =
255+
nextLinkStart > 0 && line[nextLinkStart - 1] === '!'
256+
? nextLinkStart - 1
257+
: nextLinkStart
258+
output += line.slice(cursor, linkStart)
259+
260+
const labelStart = nextLinkStart
261+
const labelEnd = findClosingBracket(line, labelStart)
262+
if (labelEnd === -1) {
263+
output += line.slice(linkStart)
264+
break
265+
}
266+
267+
if (line[labelEnd + 1] !== '(') {
268+
output += line.slice(linkStart, nextLinkStart + 1)
269+
cursor = nextLinkStart + 1
270+
continue
271+
}
272+
273+
const destination = readMarkdownDestination(line, labelEnd + 2)
274+
if (!destination) {
275+
output += line.slice(linkStart, nextLinkStart + 1)
276+
cursor = nextLinkStart + 1
277+
continue
278+
}
279+
280+
const rewritten = rewriteMarkdownDestination({
281+
context,
282+
destination: destination.destination,
283+
})
284+
output +=
285+
line.slice(linkStart, destination.destinationStart) +
286+
rewritten +
287+
line.slice(destination.destinationEnd, destination.endParen + 1)
288+
cursor = destination.endParen + 1
289+
}
290+
291+
return output
292+
}
293+
294+
function rewriteLoadedSkillMarkdownDestinations({
295+
content,
296+
packageRoot,
297+
skillFilePath,
298+
}: {
299+
content: string
300+
packageRoot: string
301+
skillFilePath: string
302+
}): string {
303+
const context: MarkdownDestinationRewriteContext = {
304+
cwd: process.cwd(),
305+
resolvedPackageRoot: resolveFromCwd(packageRoot),
306+
skillDir: dirname(skillFilePath),
307+
}
308+
let inFence: '`' | '~' | null = null
309+
const parts = content.split(/(\r?\n)/)
310+
let output = ''
311+
312+
for (let index = 0; index < parts.length; index += 2) {
313+
const line = parts[index] ?? ''
314+
const newline = parts[index + 1] ?? ''
315+
const marker = getCodeFenceMarker(line)
316+
317+
if (inFence) {
318+
output += line + newline
319+
if (marker === inFence) inFence = null
320+
continue
321+
}
322+
323+
if (marker) {
324+
inFence = marker
325+
output += line + newline
326+
continue
327+
}
328+
329+
output +=
330+
rewriteMarkdownLineDestinations({
331+
context,
332+
line,
333+
}) + newline
334+
}
335+
336+
return output
337+
}
338+
30339
export async function runLoadCommand(
31340
use: string | undefined,
32341
options: LoadCommandOptions,
@@ -64,7 +373,11 @@ export async function runLoadCommand(
64373
return
65374
}
66375

67-
const content = readFileSync(resolvedPath, 'utf8')
376+
const content = rewriteLoadedSkillMarkdownDestinations({
377+
content: readFileSync(resolvedPath, 'utf8'),
378+
packageRoot: resolved.packageRoot,
379+
skillFilePath: resolvedPath,
380+
})
68381

69382
if (options.json) {
70383
console.log(

0 commit comments

Comments
 (0)