@softize/opus 11.1.0 → 12.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.
- package/CHANGELOG.md +59 -1
- package/README.md +31 -1
- package/bin/cli.mjs +97 -27
- package/bin/lib/check.mjs +55 -43
- package/bin/lib/copy.mjs +2202 -0
- package/bin/lib/create.mjs +227 -39
- package/bin/lib/db-migrate-runner.mjs +9 -6
- package/bin/lib/db-project-path.mjs +20 -0
- package/bin/lib/db-scaffold-runner.mjs +23 -8
- package/bin/lib/db.mjs +6 -4
- package/bin/lib/gen.mjs +60 -29
- package/bin/lib/init.mjs +212 -56
- package/bin/lib/introspect.mjs +3 -2
- package/bin/lib/materialize.mjs +623 -97
- package/bin/lib/postinstall.mjs +6 -5
- package/bin/lib/validate-skill.mjs +502 -30
- package/docs/code-style.md +142 -7
- package/docs/consumer-upgrade-propagation.md +4 -3
- package/docs/releasing.md +28 -17
- package/package.json +6 -1
- package/registry/git/pre-push.d/00-opus-copy +14 -0
- package/registry/git/pre-push.d/opus +7 -21
- package/registry/git/run-opus-pre-push.mjs +141 -0
- package/registry/hooks/opus-check-on-stop.mjs +13 -31
- package/registry/instructions/opus.md +11 -5
- package/registry/skills/build-opus-ui/SKILL.md +5 -4
- package/registry/skills/create-opus-action/SKILL.md +4 -4
- package/registry/skills/implement-opus-change/SKILL.md +7 -5
- package/registry/skills/upgrade-opus/SKILL.md +8 -4
- package/registry/skills/upgrade-opus/references/upgrade-checklist.md +4 -1
- package/registry/templates/app/package.json +4 -0
- package/registry/templates/app/pnpm-workspace.yaml +3 -2
- package/registry/templates/app/src/domains/tasks/actions/list.ts +1 -1
- package/registry/templates/monorepo/pnpm-workspace.yaml +3 -1
- package/src/ui/docs/DocBrowser.tsx +10 -2
- package/src/ui/docs/content/cli.md +8 -7
- package/src/ui/docs/content/communication.md +83 -0
- package/src/ui/docs/content/getting-started.md +29 -16
- package/src/ui/docs/registry.tsx +2 -2
- package/registry/skills/write-product-communication/SKILL.md +0 -28
- package/registry/skills/write-product-communication/agents/openai.yaml +0 -4
- package/registry/templates/app/_npmrc +0 -1
- package/registry/templates/monorepo/_npmrc +0 -1
- package/src/ui/docs/content/microcopy.md +0 -130
package/bin/lib/postinstall.mjs
CHANGED
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
* ["@softize/opus"]`) ou aprovar. Sob npm roda direto.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { promises as fs } from 'node:fs'
|
|
18
17
|
import path from 'node:path'
|
|
19
18
|
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import { canonicalProjectDirectory, readProjectFile } from '@softize/base/project-path'
|
|
20
20
|
|
|
21
21
|
import { initProject } from './init.mjs'
|
|
22
22
|
|
|
@@ -26,19 +26,20 @@ const REGISTRY_DIR = path.join(PACKAGE_ROOT, 'registry')
|
|
|
26
26
|
async function main() {
|
|
27
27
|
if (process.env.OPUS_SKIP_INIT) return
|
|
28
28
|
|
|
29
|
-
const
|
|
29
|
+
const requestedProjectDir = process.env.INIT_CWD || process.cwd()
|
|
30
|
+
const projectDir = canonicalProjectDirectory(requestedProjectDir)
|
|
30
31
|
// Não inicializa o próprio Opus.
|
|
31
32
|
if (projectDir === PACKAGE_ROOT || projectDir.startsWith(PACKAGE_ROOT + path.sep)) return
|
|
32
33
|
|
|
33
34
|
let pkg
|
|
34
35
|
try {
|
|
35
|
-
pkg = JSON.parse(
|
|
36
|
+
pkg = JSON.parse(readProjectFile(projectDir, 'package.json').content)
|
|
36
37
|
} catch {
|
|
37
38
|
return // Sem package.json → não é projeto.
|
|
38
39
|
}
|
|
39
40
|
if (pkg.name === '@softize/opus') return
|
|
40
41
|
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }
|
|
41
|
-
if (!deps['@softize/opus']) return // Não é consumidor
|
|
42
|
+
if (!deps['@softize/opus']) return // Não é consumidor do Opus.
|
|
42
43
|
|
|
43
44
|
const r = await initProject(REGISTRY_DIR, projectDir)
|
|
44
45
|
if (!r.materialization.ok) {
|
|
@@ -47,7 +48,7 @@ async function main() {
|
|
|
47
48
|
}
|
|
48
49
|
const n = r.created.length + r.synced.length
|
|
49
50
|
console.log(
|
|
50
|
-
`\x1b[36m[opus]\x1b[0m
|
|
51
|
+
`\x1b[36m[opus]\x1b[0m Opus v${r.version} — ${r.wasInitialized ? 'sincronizado' : 'inicializado'}` +
|
|
51
52
|
(n ? ` (${n} arquivo(s))` : '') +
|
|
52
53
|
`. Veja base.json / opus.json.`,
|
|
53
54
|
)
|
|
@@ -1,40 +1,512 @@
|
|
|
1
|
-
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
2
|
-
import { basename, join } from 'node:path'
|
|
1
|
+
import { closeSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, readdirSync, realpathSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
3
|
+
import MarkdownIt from 'markdown-it'
|
|
4
|
+
import parseHtmlInline from 'markdown-it/lib/rules_inline/html_inline.mjs'
|
|
3
5
|
|
|
4
|
-
const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
5
6
|
const PROHIBITED = new Set(['README.md', 'CHANGELOG.md', 'INSTALLATION_GUIDE.md', 'QUICK_REFERENCE.md'])
|
|
7
|
+
const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
8
|
+
const ROUTE = /^\s*<!--\s+softize-skill-route:\s+\$([a-z0-9]+(?:-[a-z0-9]+)*)\s+-->\s*$/
|
|
9
|
+
const ROUTE_ATTEMPT = /softize-skill-\s*route\b/gu
|
|
10
|
+
const URI_SCHEME = /^[a-z][a-z0-9+.-]*:/iu
|
|
11
|
+
const DRIVE_DESIGNATOR = /^[a-z]:/iu
|
|
12
|
+
const WINDOWS_ROOTED = /^(?:\\|%5c)/iu
|
|
13
|
+
const UNC_PATH = /^(?:\\\\|\/\/)/u
|
|
14
|
+
const ENCODED_SEPARATOR_OR_NUL = /%(?:00|2f|5c)/iu
|
|
15
|
+
const MARKDOWN = new MarkdownIt({ html: true })
|
|
16
|
+
const NATIVE_PATH = { isAbsolute, relative, sep }
|
|
17
|
+
const markdownItValidateLink = MARKDOWN.validateLink.bind(MARKDOWN)
|
|
18
|
+
MARKDOWN.validateLink = (target) => /^file:/iu.test(target) || markdownItValidateLink(target)
|
|
19
|
+
MARKDOWN.inline.ruler.at('html_inline', (state, silent) => {
|
|
20
|
+
const sourceStart = state.pos
|
|
21
|
+
const matched = parseHtmlInline(state, silent)
|
|
22
|
+
if (matched && !silent) {
|
|
23
|
+
const token = state.tokens.at(-1)
|
|
24
|
+
if (token?.type === 'html_inline') token.meta = { ...token.meta, sourceStart }
|
|
25
|
+
}
|
|
26
|
+
return matched
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
function markdownDocument(text) {
|
|
30
|
+
const lines = text.split(/\r?\n/u)
|
|
31
|
+
const possibleFrontmatterEnd = lines[0] === '---' ? lines.indexOf('---', 1) : -1
|
|
32
|
+
const frontmatterEnd =
|
|
33
|
+
possibleFrontmatterEnd > 1 && lines.slice(1, possibleFrontmatterEnd).some((line) => /^[a-zA-Z0-9_-]+:\s*/u.test(line))
|
|
34
|
+
? possibleFrontmatterEnd
|
|
35
|
+
: -1
|
|
36
|
+
const lineOffset = frontmatterEnd + 1
|
|
37
|
+
const env = {}
|
|
38
|
+
return {
|
|
39
|
+
env,
|
|
40
|
+
lines,
|
|
41
|
+
lineOffset,
|
|
42
|
+
tokens: MARKDOWN.parse(lines.slice(lineOffset).join('\n'), env),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function lineAt(content, offset, firstLine) {
|
|
47
|
+
let line = firstLine
|
|
48
|
+
for (let index = 0; index < offset; index += 1) {
|
|
49
|
+
if (content[index] === '\n') line += 1
|
|
50
|
+
}
|
|
51
|
+
return line
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function attemptLines(content, firstLine) {
|
|
55
|
+
return [...content.matchAll(ROUTE_ATTEMPT)].map((match) => lineAt(content, match.index, firstLine))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function analyzeRoutes(text) {
|
|
59
|
+
const { lines, lineOffset, tokens } = markdownDocument(text)
|
|
60
|
+
const references = []
|
|
61
|
+
const errors = new Set()
|
|
62
|
+
const operational = new Set()
|
|
63
|
+
|
|
64
|
+
for (const token of tokens) {
|
|
65
|
+
if (token.map === null) continue
|
|
66
|
+
const firstLine = lineOffset + token.map[0]
|
|
67
|
+
const lastLine = lineOffset + token.map[1]
|
|
68
|
+
|
|
69
|
+
if (token.type === 'html_block') {
|
|
70
|
+
const route = lastLine === firstLine + 1 ? ROUTE.exec(token.content) : null
|
|
71
|
+
const attempts = attemptLines(token.content, firstLine)
|
|
72
|
+
if (route !== null) {
|
|
73
|
+
references.push(route[1])
|
|
74
|
+
operational.add(firstLine)
|
|
75
|
+
} else {
|
|
76
|
+
for (const line of attempts) errors.add(line)
|
|
77
|
+
if (lastLine === firstLine + 1) operational.add(firstLine)
|
|
78
|
+
}
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
6
81
|
|
|
7
|
-
|
|
8
|
-
|
|
82
|
+
if (token.type !== 'inline') continue
|
|
83
|
+
for (const child of token.children ?? []) {
|
|
84
|
+
if (child.type !== 'html_inline') continue
|
|
85
|
+
const childLine = lineAt(token.content, child.meta.sourceStart, firstLine)
|
|
86
|
+
for (let line = childLine; line <= childLine + child.content.split('\n').length - 1; line += 1) {
|
|
87
|
+
operational.add(line)
|
|
88
|
+
}
|
|
89
|
+
for (const line of attemptLines(child.content, childLine)) errors.add(line)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { errors: [...errors].sort((left, right) => left - right), lines, operational, references }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function operationalMarkdownLines(text) {
|
|
97
|
+
const analysis = analyzeRoutes(text)
|
|
98
|
+
return analysis.lines.map((line, index) => ({ line, index, operational: analysis.operational.has(index) }))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function frontmatter(text) {
|
|
102
|
+
const match = /^---\n([\s\S]*?)\n---\n/.exec(text)
|
|
9
103
|
if (match === null) return null
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
104
|
+
const fields = new Map()
|
|
105
|
+
for (const line of match[1].split('\n')) {
|
|
106
|
+
const field = /^([a-zA-Z0-9_-]+):\s*(.*)$/.exec(line)
|
|
107
|
+
if (field !== null) fields.set(field[1], field[2].replace(/^['"]|['"]$/g, '').trim())
|
|
108
|
+
}
|
|
109
|
+
return fields
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function referencedSkills(text) {
|
|
113
|
+
return [...new Set(analyzeRoutes(text).references)]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function skillRouteErrors(text) {
|
|
117
|
+
return analyzeRoutes(text).errors.map((line) => `encaminhamento operacional malformado na linha ${line + 1}.`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function isPathInside(root, target, pathApi = NATIVE_PATH) {
|
|
121
|
+
const path = pathApi.relative(root, target)
|
|
122
|
+
return path === '' || (path !== '..' && !path.startsWith(`..${pathApi.sep}`) && !pathApi.isAbsolute(path))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function referenceTargetKind(target) {
|
|
126
|
+
if (DRIVE_DESIGNATOR.test(target) || WINDOWS_ROOTED.test(target) || UNC_PATH.test(target)) {
|
|
127
|
+
return 'absolute-filesystem'
|
|
128
|
+
}
|
|
129
|
+
if (/^file:/iu.test(target)) return 'unsupported-file-url'
|
|
130
|
+
if (URI_SCHEME.test(target)) return 'external-url'
|
|
131
|
+
return 'local'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sameIdentity(left, right) {
|
|
135
|
+
return left.dev === right.dev && left.ino === right.ino
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function markdownResourceError(relativePath) {
|
|
139
|
+
return `${relativePath}: recurso Markdown deve ser arquivo regular e contido na skill.`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function entryExists(path) {
|
|
143
|
+
try {
|
|
144
|
+
lstatSync(path)
|
|
145
|
+
return true
|
|
146
|
+
} catch {
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function inspectRegularResource(root, canonicalRoot, path, error) {
|
|
152
|
+
try {
|
|
153
|
+
const info = lstatSync(path)
|
|
154
|
+
const canonical = realpathSync(path)
|
|
155
|
+
if (
|
|
156
|
+
!info.isFile() ||
|
|
157
|
+
info.isSymbolicLink() ||
|
|
158
|
+
!isPathInside(root, path) ||
|
|
159
|
+
!isPathInside(canonicalRoot, canonical)
|
|
160
|
+
) {
|
|
161
|
+
return { error }
|
|
162
|
+
}
|
|
163
|
+
return { canonical, info }
|
|
164
|
+
} catch {
|
|
165
|
+
return { error }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function inspectMarkdownResource(root, canonicalRoot, path, relativePath) {
|
|
170
|
+
const inspected = inspectRegularResource(root, canonicalRoot, path, markdownResourceError(relativePath))
|
|
171
|
+
if (inspected.error !== undefined) return inspected
|
|
172
|
+
return { resource: { path, relative: relativePath } }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function inspectReferenceDirectory(root, canonicalRoot, path, relativePath) {
|
|
176
|
+
try {
|
|
177
|
+
const info = lstatSync(path)
|
|
178
|
+
const canonical = realpathSync(path)
|
|
179
|
+
if (
|
|
180
|
+
!info.isDirectory() ||
|
|
181
|
+
info.isSymbolicLink() ||
|
|
182
|
+
!isPathInside(root, path) ||
|
|
183
|
+
!isPathInside(canonicalRoot, canonical)
|
|
184
|
+
) {
|
|
185
|
+
return { error: `${relativePath}: diretório de referências deve ser real e contido na skill.` }
|
|
186
|
+
}
|
|
187
|
+
return { canonical, info }
|
|
188
|
+
} catch {
|
|
189
|
+
return { error: `${relativePath}: diretório de referências deve ser real e contido na skill.` }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function collectReferenceResources(root, canonicalRoot, current, resources, errors) {
|
|
194
|
+
const currentRelative = relative(root, current)
|
|
195
|
+
const before = inspectReferenceDirectory(root, canonicalRoot, current, currentRelative)
|
|
196
|
+
if (before.error !== undefined) {
|
|
197
|
+
errors.push(before.error)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let entries
|
|
202
|
+
try {
|
|
203
|
+
entries = readdirSync(current, { withFileTypes: true })
|
|
204
|
+
} catch {
|
|
205
|
+
errors.push(`${currentRelative}: diretório de referências não pôde ser lido com segurança.`)
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const after = inspectReferenceDirectory(root, canonicalRoot, current, currentRelative)
|
|
210
|
+
if (
|
|
211
|
+
after.error !== undefined ||
|
|
212
|
+
!sameIdentity(before.info, after.info) ||
|
|
213
|
+
before.canonical !== after.canonical
|
|
214
|
+
) {
|
|
215
|
+
errors.push(after.error ?? `${currentRelative}: diretório de referências mudou durante a leitura.`)
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const entry of entries) {
|
|
220
|
+
const path = join(current, entry.name)
|
|
221
|
+
const relativePath = relative(root, path)
|
|
222
|
+
if (entry.isSymbolicLink()) {
|
|
223
|
+
errors.push(
|
|
224
|
+
extname(entry.name) === '.md'
|
|
225
|
+
? markdownResourceError(relativePath)
|
|
226
|
+
: `${relativePath}: entrada simbólica não é permitida em references.`,
|
|
227
|
+
)
|
|
228
|
+
} else if (entry.isDirectory()) {
|
|
229
|
+
collectReferenceResources(root, canonicalRoot, path, resources, errors)
|
|
230
|
+
} else if (extname(entry.name) === '.md') {
|
|
231
|
+
const inspected = inspectMarkdownResource(root, canonicalRoot, path, relativePath)
|
|
232
|
+
if (inspected.error !== undefined) errors.push(inspected.error)
|
|
233
|
+
else resources.push(inspected.resource)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
15
236
|
}
|
|
16
237
|
|
|
17
|
-
|
|
238
|
+
function markdownResources(root) {
|
|
239
|
+
const resources = []
|
|
18
240
|
const errors = []
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
241
|
+
let canonicalRoot
|
|
242
|
+
try {
|
|
243
|
+
const rootInfo = lstatSync(root)
|
|
244
|
+
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
|
|
245
|
+
return { errors: ['raiz da skill deve ser um diretório real.'], resources }
|
|
246
|
+
}
|
|
247
|
+
canonicalRoot = realpathSync(root)
|
|
248
|
+
} catch {
|
|
249
|
+
return { errors: ['raiz da skill deve ser um diretório real.'], resources }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const skill = join(root, 'SKILL.md')
|
|
253
|
+
if (!entryExists(skill)) errors.push('SKILL.md ausente.')
|
|
25
254
|
else {
|
|
26
|
-
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
255
|
+
const inspected = inspectMarkdownResource(root, canonicalRoot, skill, 'SKILL.md')
|
|
256
|
+
if (inspected.error !== undefined) errors.push(inspected.error)
|
|
257
|
+
else resources.push(inspected.resource)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const references = join(root, 'references')
|
|
261
|
+
if (entryExists(references)) collectReferenceResources(root, canonicalRoot, references, resources, errors)
|
|
262
|
+
return { canonicalRoot, errors, resources }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function readRegularResource(root, canonicalRoot, resource, error) {
|
|
266
|
+
let descriptor
|
|
267
|
+
try {
|
|
268
|
+
const before = lstatSync(resource.path)
|
|
269
|
+
const canonicalBefore = realpathSync(resource.path)
|
|
270
|
+
if (
|
|
271
|
+
!before.isFile() ||
|
|
272
|
+
before.isSymbolicLink() ||
|
|
273
|
+
!isPathInside(root, resource.path) ||
|
|
274
|
+
!isPathInside(canonicalRoot, canonicalBefore)
|
|
275
|
+
) {
|
|
276
|
+
return { error }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
descriptor = openSync(resource.path, 'r')
|
|
280
|
+
const opened = fstatSync(descriptor)
|
|
281
|
+
if (!opened.isFile() || !sameIdentity(before, opened)) {
|
|
282
|
+
return { error }
|
|
283
|
+
}
|
|
284
|
+
const text = readFileSync(descriptor, 'utf8')
|
|
285
|
+
const after = fstatSync(descriptor)
|
|
286
|
+
const atPath = lstatSync(resource.path)
|
|
287
|
+
const canonicalAfter = realpathSync(resource.path)
|
|
288
|
+
if (
|
|
289
|
+
!after.isFile() ||
|
|
290
|
+
atPath.isSymbolicLink() ||
|
|
291
|
+
!sameIdentity(opened, after) ||
|
|
292
|
+
!sameIdentity(opened, atPath) ||
|
|
293
|
+
opened.size !== after.size ||
|
|
294
|
+
opened.mtimeMs !== after.mtimeMs ||
|
|
295
|
+
canonicalBefore !== canonicalAfter ||
|
|
296
|
+
!isPathInside(canonicalRoot, canonicalAfter)
|
|
297
|
+
) {
|
|
298
|
+
return { error }
|
|
299
|
+
}
|
|
300
|
+
return { text }
|
|
301
|
+
} catch {
|
|
302
|
+
return { error }
|
|
303
|
+
} finally {
|
|
304
|
+
if (descriptor !== undefined) closeSync(descriptor)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function readMarkdownResource(root, canonicalRoot, resource) {
|
|
309
|
+
return readRegularResource(root, canonicalRoot, resource, markdownResourceError(resource.relative))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function readOpenaiMetadata(root, canonicalRoot) {
|
|
313
|
+
if (canonicalRoot === undefined) return {}
|
|
314
|
+
const agents = join(root, 'agents')
|
|
315
|
+
if (!entryExists(agents)) return { error: 'agents/openai.yaml ausente.' }
|
|
316
|
+
const directoryError = 'agents: diretório deve ser real e contido na skill.'
|
|
317
|
+
const before = inspectReferenceDirectory(root, canonicalRoot, agents, 'agents')
|
|
318
|
+
if (before.error !== undefined) return { error: directoryError }
|
|
319
|
+
|
|
320
|
+
const openai = join(agents, 'openai.yaml')
|
|
321
|
+
if (!entryExists(openai)) return { error: 'agents/openai.yaml ausente.' }
|
|
322
|
+
const fileError = 'agents/openai.yaml deve ser arquivo regular e contido na skill.'
|
|
323
|
+
const inspected = inspectRegularResource(root, canonicalRoot, openai, fileError)
|
|
324
|
+
if (inspected.error !== undefined) return inspected
|
|
325
|
+
const read = readRegularResource(root, canonicalRoot, { path: openai }, fileError)
|
|
326
|
+
if (read.error !== undefined) return read
|
|
327
|
+
|
|
328
|
+
const after = inspectReferenceDirectory(root, canonicalRoot, agents, 'agents')
|
|
329
|
+
if (
|
|
330
|
+
after.error !== undefined ||
|
|
331
|
+
!sameIdentity(before.info, after.info) ||
|
|
332
|
+
before.canonical !== after.canonical
|
|
333
|
+
) {
|
|
334
|
+
return { error: directoryError }
|
|
335
|
+
}
|
|
336
|
+
return { text: read.text }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function readMarkdownResources(root, inventory) {
|
|
340
|
+
const errors = [...inventory.errors]
|
|
341
|
+
const resources = []
|
|
342
|
+
if (inventory.canonicalRoot === undefined) return { errors, resources }
|
|
343
|
+
for (const resource of inventory.resources) {
|
|
344
|
+
const read = readMarkdownResource(root, inventory.canonicalRoot, resource)
|
|
345
|
+
if (read.error !== undefined) errors.push(read.error)
|
|
346
|
+
else resources.push({ ...resource, text: read.text })
|
|
347
|
+
}
|
|
348
|
+
return { errors, resources }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function htmlWhitespace(code) {
|
|
352
|
+
return code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d || code === 0x20
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function containsOnlyHtmlComments(content) {
|
|
356
|
+
let cursor = 0
|
|
357
|
+
let comments = 0
|
|
358
|
+
|
|
359
|
+
while (cursor < content.length) {
|
|
360
|
+
while (cursor < content.length && htmlWhitespace(content.charCodeAt(cursor))) cursor += 1
|
|
361
|
+
if (cursor === content.length) break
|
|
362
|
+
if (!content.startsWith('<!--', cursor)) return false
|
|
363
|
+
const end = content.indexOf('-->', cursor + 4)
|
|
364
|
+
if (end === -1) return false
|
|
365
|
+
comments += 1
|
|
366
|
+
cursor = end + 3
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return comments > 0
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function markdownLinks(text) {
|
|
373
|
+
const { env, tokens } = markdownDocument(text)
|
|
374
|
+
const targets = new Set()
|
|
375
|
+
const errors = new Set()
|
|
376
|
+
const visit = (token) => {
|
|
377
|
+
if (token.type === 'link_open') targets.add(token.attrGet('href'))
|
|
378
|
+
else if (token.type === 'image') targets.add(token.attrGet('src'))
|
|
379
|
+
else if (
|
|
380
|
+
(token.type === 'html_inline' || token.type === 'html_block') &&
|
|
381
|
+
!containsOnlyHtmlComments(token.content)
|
|
382
|
+
) errors.add('HTML ou SVG cru renderizável não é permitido; use Markdown ou um recurso referenciado.')
|
|
383
|
+
for (const child of token.children ?? []) visit(child)
|
|
384
|
+
}
|
|
385
|
+
for (const token of tokens) visit(token)
|
|
386
|
+
for (const reference of Object.values(env.references ?? {})) targets.add(reference.href)
|
|
387
|
+
return {
|
|
388
|
+
errors: [...errors],
|
|
389
|
+
targets: [...targets].filter((target) => typeof target === 'string'),
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function validLinkTarget(root, source, renderedTarget) {
|
|
394
|
+
if (
|
|
395
|
+
renderedTarget.trim() !== renderedTarget ||
|
|
396
|
+
/[\u0000-\u001f\u007f]/u.test(renderedTarget) ||
|
|
397
|
+
!markdownItValidateLink(renderedTarget)
|
|
398
|
+
) {
|
|
399
|
+
return false
|
|
400
|
+
}
|
|
401
|
+
const kind = referenceTargetKind(renderedTarget)
|
|
402
|
+
if (kind === 'external-url') return true
|
|
403
|
+
if (kind !== 'local') return false
|
|
404
|
+
const end = renderedTarget.search(/[?#]/u)
|
|
405
|
+
const encodedPath = end === -1 ? renderedTarget : renderedTarget.slice(0, end)
|
|
406
|
+
if (encodedPath === '') return true
|
|
407
|
+
if (encodedPath.includes('\\') || ENCODED_SEPARATOR_OR_NUL.test(encodedPath)) return false
|
|
408
|
+
let pathname
|
|
409
|
+
try {
|
|
410
|
+
pathname = decodeURIComponent(encodedPath)
|
|
411
|
+
} catch {
|
|
412
|
+
return false
|
|
413
|
+
}
|
|
414
|
+
if (pathname.includes('\0') || referenceTargetKind(pathname) !== 'local') return false
|
|
415
|
+
const path = resolve(dirname(source), pathname)
|
|
416
|
+
if (!isPathInside(root, path) || !existsSync(path)) return false
|
|
417
|
+
try {
|
|
418
|
+
return isPathInside(realpathSync(root), realpathSync(path))
|
|
419
|
+
} catch {
|
|
420
|
+
return false
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function printableLinkTarget(target) {
|
|
425
|
+
return target.replace(/[\u0000-\u001f\u007f]/gu, (character) =>
|
|
426
|
+
`\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`,
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function skillReferences(dir) {
|
|
431
|
+
const root = resolve(dir)
|
|
432
|
+
const loaded = readMarkdownResources(root, markdownResources(root))
|
|
433
|
+
return loaded.resources
|
|
434
|
+
.filter((resource) => basename(resource.path) !== 'evaluations.md')
|
|
435
|
+
.flatMap((resource) =>
|
|
436
|
+
referencedSkills(resource.text).map((dependency) => ({
|
|
437
|
+
dependency,
|
|
438
|
+
source: resource.relative,
|
|
439
|
+
})),
|
|
440
|
+
)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function validateSkill(dir) {
|
|
444
|
+
const root = resolve(dir)
|
|
445
|
+
const inventory = markdownResources(root)
|
|
446
|
+
const loaded = readMarkdownResources(root, inventory)
|
|
447
|
+
const openai = readOpenaiMetadata(root, inventory.canonicalRoot)
|
|
448
|
+
const errors = [...loaded.errors]
|
|
449
|
+
if (openai.error !== undefined) errors.push(openai.error)
|
|
450
|
+
if (inventory.canonicalRoot !== undefined) {
|
|
451
|
+
try {
|
|
452
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
453
|
+
if (PROHIBITED.has(entry.name)) errors.push(`${entry.name} não pertence a uma skill.`)
|
|
454
|
+
}
|
|
455
|
+
} catch {
|
|
456
|
+
errors.push('raiz da skill não pôde ser lida com segurança.')
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const skillResource = loaded.resources.find((resource) => resource.relative === 'SKILL.md')
|
|
460
|
+
if (skillResource === undefined) return errors
|
|
461
|
+
|
|
462
|
+
const text = skillResource.text
|
|
463
|
+
const meta = frontmatter(text)
|
|
464
|
+
if (meta === null) errors.push('frontmatter YAML ausente ou inválido.')
|
|
465
|
+
else {
|
|
466
|
+
const keys = [...meta.keys()]
|
|
467
|
+
if (keys.length !== 2 || !meta.has('name') || !meta.has('description')) {
|
|
468
|
+
errors.push('frontmatter deve conter somente name e description.')
|
|
469
|
+
}
|
|
470
|
+
const name = meta.get('name') ?? ''
|
|
471
|
+
const description = meta.get('description') ?? ''
|
|
472
|
+
if (!NAME.test(name) || name.length > 64) errors.push('name deve ser lowercase kebab-case e ter até 64 caracteres.')
|
|
473
|
+
if (name !== basename(root)) errors.push(`name deve ser igual à pasta (${basename(root)}).`)
|
|
474
|
+
if (description.length === 0 || description.length > 1024) errors.push('description deve ter entre 1 e 1024 caracteres.')
|
|
475
|
+
if (!/\b(use|usar|quando|ao)\b/i.test(description)) errors.push('description deve declarar quando usar a skill.')
|
|
476
|
+
}
|
|
477
|
+
if (text.split('\n').length > 500) errors.push('SKILL.md deve ter no máximo 500 linhas.')
|
|
478
|
+
if (/\b(TODO|PLACEHOLDER)\b/.test(text)) errors.push('SKILL.md contém placeholder.')
|
|
479
|
+
|
|
480
|
+
for (const resource of loaded.resources.filter((resource) => basename(resource.path) !== 'evaluations.md')) {
|
|
481
|
+
for (const error of skillRouteErrors(resource.text)) {
|
|
482
|
+
errors.push(`${resource.relative}: ${error}`)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (openai.text !== undefined) {
|
|
487
|
+
const ui = openai.text
|
|
488
|
+
const name = meta?.get('name') ?? basename(root)
|
|
489
|
+
if (!ui.includes('display_name:') || !ui.includes('short_description:')) errors.push('openai.yaml sem metadados de interface.')
|
|
490
|
+
if (!ui.includes(`$${name}`)) errors.push(`default_prompt deve mencionar $${name}.`)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
for (const resource of loaded.resources) {
|
|
494
|
+
const links = markdownLinks(resource.text)
|
|
495
|
+
for (const error of links.errors) errors.push(`${resource.relative}: ${error}`)
|
|
496
|
+
for (const target of links.targets) {
|
|
497
|
+
if (!validLinkTarget(root, resource.path, target)) {
|
|
498
|
+
errors.push(`${resource.relative}: referência local inválida: ${printableLinkTarget(target)}.`)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
for (const resource of loaded.resources.filter((resource) => basename(resource.path) !== 'evaluations.md')) {
|
|
504
|
+
for (const dependency of referencedSkills(resource.text)) {
|
|
505
|
+
if (!existsSync(join(dirname(root), dependency, 'SKILL.md'))) {
|
|
506
|
+
errors.push(`${resource.relative}: skill encaminhada ausente: $${dependency}.`)
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
39
511
|
return errors
|
|
40
512
|
}
|