mustflow 2.99.2 → 2.103.3
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/dist/cli/commands/skill.js +76 -2
- package/dist/cli/lib/external-skill-import.js +391 -0
- package/dist/cli/lib/local-index/index.js +5 -1
- package/dist/core/public-json-contracts.js +16 -0
- package/dist/core/skill-route-resolution.js +54 -6
- package/package.json +1 -1
- package/schemas/README.md +3 -0
- package/schemas/skill-import-report.schema.json +97 -0
- package/templates/default/i18n.toml +36 -6
- package/templates/default/locales/en/.mustflow/skills/INDEX.md +22 -2
- package/templates/default/locales/en/.mustflow/skills/c-code-change/SKILL.md +371 -0
- package/templates/default/locales/en/.mustflow/skills/clarifying-question-gate/SKILL.md +53 -14
- package/templates/default/locales/en/.mustflow/skills/completion-evidence-gate/SKILL.md +15 -3
- package/templates/default/locales/en/.mustflow/skills/css-code-change/SKILL.md +74 -24
- package/templates/default/locales/en/.mustflow/skills/docs-prose-review/SKILL.md +36 -10
- package/templates/default/locales/en/.mustflow/skills/github-contribution-quality-gate/SKILL.md +27 -3
- package/templates/default/locales/en/.mustflow/skills/html-code-change/SKILL.md +37 -21
- package/templates/default/locales/en/.mustflow/skills/react-code-change/SKILL.md +278 -0
- package/templates/default/locales/en/.mustflow/skills/routes.toml +24 -0
- package/templates/default/locales/en/.mustflow/skills/shell-code-change/SKILL.md +279 -0
- package/templates/default/locales/en/.mustflow/skills/structured-config-change/SKILL.md +170 -0
- package/templates/default/manifest.toml +29 -1
|
@@ -5,28 +5,41 @@ import { resolveMustflowRoot } from '../lib/project-root.js';
|
|
|
5
5
|
import { listScriptPackScripts } from '../lib/script-pack-registry.js';
|
|
6
6
|
import { createScriptPackSuggestionReport, } from '../../core/script-pack-suggestions.js';
|
|
7
7
|
import { resolveSkillRoutes } from '../../core/skill-route-resolution.js';
|
|
8
|
+
import { createExternalSkillImportReport, } from '../lib/external-skill-import.js';
|
|
8
9
|
const SKILL_OPTIONS = [
|
|
9
10
|
{ name: '--json', kind: 'boolean' },
|
|
10
11
|
{ name: '--task', kind: 'string' },
|
|
11
12
|
{ name: '--path', kind: 'string' },
|
|
12
13
|
{ name: '--reason', kind: 'string' },
|
|
13
14
|
{ name: '--max-candidates', kind: 'string' },
|
|
15
|
+
{ name: '--install', kind: 'boolean' },
|
|
16
|
+
{ name: '--dry-run', kind: 'boolean' },
|
|
17
|
+
{ name: '--name', kind: 'string' },
|
|
18
|
+
{ name: '--ref', kind: 'string' },
|
|
14
19
|
];
|
|
15
20
|
export function getSkillHelp(lang = 'en') {
|
|
16
21
|
return renderHelp({
|
|
17
22
|
usage: 'mf skill route [options]',
|
|
18
23
|
summary: t(lang, 'command.skill.summary'),
|
|
19
24
|
options: [
|
|
25
|
+
{ label: 'route', description: 'Resolve installed skill route candidates' },
|
|
26
|
+
{ label: 'import <github-url>', description: 'Preview or install an external SKILL.md under .mustflow/external-skills/' },
|
|
20
27
|
{ label: '--task <text>', description: 'Task text used for route scoring' },
|
|
21
28
|
{ label: '--path <path>', description: 'Changed or expected path; may be repeated' },
|
|
22
29
|
{ label: '--reason <reason>', description: 'Classification or verification reason; may be repeated' },
|
|
23
30
|
{ label: '--max-candidates <count>', description: 'Maximum candidates to return, from 1 to 10' },
|
|
31
|
+
{ label: '--dry-run', description: 'Preview an external skill import without writing files; default for import' },
|
|
32
|
+
{ label: '--install', description: 'Install an external skill after previewing the same source' },
|
|
33
|
+
{ label: '--name <slug>', description: 'Override the installed external skill directory name' },
|
|
34
|
+
{ label: '--ref <ref>', description: 'Override the GitHub ref used for import' },
|
|
24
35
|
{ label: '--json', description: t(lang, 'cli.option.json') },
|
|
25
36
|
{ label: '-h, --help', description: t(lang, 'cli.option.help') },
|
|
26
37
|
],
|
|
27
38
|
examples: [
|
|
28
39
|
'mf skill route --task "change TypeScript CLI output" --path src/cli/index.ts --reason code_change --json',
|
|
29
40
|
'mf skill route --reason docs_change --path docs-site/src/content/docs/en/commands/context.md',
|
|
41
|
+
'mf skill import https://github.com/example/agent-skills/tree/main/review/security --dry-run --json',
|
|
42
|
+
'mf skill import https://github.com/example/agent-skills/blob/main/review/security/SKILL.md --install',
|
|
30
43
|
],
|
|
31
44
|
exitCodes: [
|
|
32
45
|
{ label: '0', description: 'Skill route candidates were resolved' },
|
|
@@ -51,10 +64,15 @@ function parseSkillArgs(args) {
|
|
|
51
64
|
return {
|
|
52
65
|
json: hasParsedCliOption(parsed, '--json'),
|
|
53
66
|
action: parsed.positionals[0] ?? null,
|
|
67
|
+
sourceUrl: parsed.positionals[1] ?? null,
|
|
54
68
|
taskText: getParsedCliStringOption(parsed, '--task'),
|
|
55
69
|
paths: getParsedCliStringOptions(parsed, '--path'),
|
|
56
70
|
reasons: getParsedCliStringOptions(parsed, '--reason'),
|
|
57
71
|
maxCandidates: parseMaxCandidates(getParsedCliStringOption(parsed, '--max-candidates')),
|
|
72
|
+
install: hasParsedCliOption(parsed, '--install'),
|
|
73
|
+
dryRun: hasParsedCliOption(parsed, '--dry-run'),
|
|
74
|
+
name: getParsedCliStringOption(parsed, '--name'),
|
|
75
|
+
ref: getParsedCliStringOption(parsed, '--ref'),
|
|
58
76
|
error: parsed.error,
|
|
59
77
|
};
|
|
60
78
|
}
|
|
@@ -101,7 +119,40 @@ function renderSkillRouteReport(report, lang) {
|
|
|
101
119
|
lines.push('', 'Read plan', ...report.read_plan.selected_skill_paths.map((skillPath) => `- read selected skill: ${skillPath}`), `- avoid by default: ${report.read_plan.avoid_by_default.join(', ') || t(lang, 'value.none')}`, `- fallback route metadata: ${report.read_plan.fallback_route_metadata.path}`, '', 'Source files', ...report.source_files.map((sourceFile) => `- ${sourceFile}`));
|
|
102
120
|
return lines.join('\n');
|
|
103
121
|
}
|
|
104
|
-
|
|
122
|
+
function renderSkillImportReport(report) {
|
|
123
|
+
const lines = [
|
|
124
|
+
'mustflow skill import',
|
|
125
|
+
`status: ${report.status}`,
|
|
126
|
+
`mode: ${report.mode}`,
|
|
127
|
+
`source: ${report.source?.source_url ?? 'none'}`,
|
|
128
|
+
`target: ${report.target?.skill_dir ?? 'none'}`,
|
|
129
|
+
`wrote_files: ${String(report.wrote_files)}`,
|
|
130
|
+
'',
|
|
131
|
+
'Files',
|
|
132
|
+
];
|
|
133
|
+
if (report.files.length === 0) {
|
|
134
|
+
lines.push('- none');
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
for (const file of report.files) {
|
|
138
|
+
lines.push(`- ${file.relative_path} (${file.kind}, ${file.bytes} bytes, ${file.sha256})`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (report.warnings.length > 0) {
|
|
142
|
+
lines.push('', 'Warnings', ...report.warnings.map((warning) => `- ${warning}`));
|
|
143
|
+
}
|
|
144
|
+
if (report.issues.length > 0) {
|
|
145
|
+
lines.push('', 'Issues', ...report.issues.map((issue) => `- ${issue}`));
|
|
146
|
+
}
|
|
147
|
+
return lines.join('\n');
|
|
148
|
+
}
|
|
149
|
+
function parseImportMode(parsed) {
|
|
150
|
+
if (parsed.install && parsed.dryRun) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
return parsed.install ? 'install' : 'dry_run';
|
|
154
|
+
}
|
|
155
|
+
export async function runSkill(args, reporter, lang = 'en') {
|
|
105
156
|
if (hasCliOptionToken(args, '--help', ['-h'])) {
|
|
106
157
|
reporter.stdout(getSkillHelp(lang));
|
|
107
158
|
return 0;
|
|
@@ -111,13 +162,36 @@ export function runSkill(args, reporter, lang = 'en') {
|
|
|
111
162
|
printUsageError(reporter, formatCliOptionParseError(parsed.error, lang), 'mf skill --help', getSkillHelp(lang), lang);
|
|
112
163
|
return 1;
|
|
113
164
|
}
|
|
114
|
-
if (parsed.action !== 'route') {
|
|
165
|
+
if (parsed.action !== 'route' && parsed.action !== 'import') {
|
|
115
166
|
printUsageError(reporter, t(lang, parsed.action ? 'cli.error.unexpectedArgument' : 'cli.error.missingValue', {
|
|
116
167
|
argument: parsed.action ?? '',
|
|
117
168
|
option: 'route',
|
|
118
169
|
}), 'mf skill --help', getSkillHelp(lang), lang);
|
|
119
170
|
return 1;
|
|
120
171
|
}
|
|
172
|
+
if (parsed.action === 'import') {
|
|
173
|
+
const mode = parseImportMode(parsed);
|
|
174
|
+
if (mode === null) {
|
|
175
|
+
printUsageError(reporter, t(lang, 'cli.error.unexpectedValue', { option: '--install/--dry-run' }), 'mf skill --help', getSkillHelp(lang), lang);
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
if (!parsed.sourceUrl) {
|
|
179
|
+
printUsageError(reporter, t(lang, 'cli.error.missingValue', { option: 'import <github-url>' }), 'mf skill --help', getSkillHelp(lang), lang);
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
|
182
|
+
const report = await createExternalSkillImportReport(resolveMustflowRoot(), parsed.sourceUrl, {
|
|
183
|
+
mode,
|
|
184
|
+
name: parsed.name,
|
|
185
|
+
ref: parsed.ref,
|
|
186
|
+
});
|
|
187
|
+
if (parsed.json) {
|
|
188
|
+
reporter.stdout(JSON.stringify(report, null, 2));
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
reporter.stdout(renderSkillImportReport(report));
|
|
192
|
+
}
|
|
193
|
+
return report.ok ? 0 : 1;
|
|
194
|
+
}
|
|
121
195
|
if (Number.isNaN(parsed.maxCandidates)) {
|
|
122
196
|
printUsageError(reporter, t(lang, 'cli.error.unexpectedValue', { option: '--max-candidates' }), 'mf skill --help', getSkillHelp(lang), lang);
|
|
123
197
|
return 1;
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { ensureFileTargetInsideWithoutSymlinks, writeJsonFileInsideWithoutSymlinks, writeUtf8FileInsideWithoutSymlinks, } from '../../core/safe-filesystem.js';
|
|
5
|
+
const EXTERNAL_SKILL_ROOT = '.mustflow/external-skills';
|
|
6
|
+
const PROVENANCE_FILE = 'mustflow-skill-source.json';
|
|
7
|
+
const DEFAULT_GITHUB_REF = 'HEAD';
|
|
8
|
+
const MAX_IMPORTED_FILES = 40;
|
|
9
|
+
const MAX_TOTAL_BYTES = 512 * 1024;
|
|
10
|
+
const MAX_FILE_BYTES = 256 * 1024;
|
|
11
|
+
const ALLOWED_SUPPORT_DIRECTORIES = new Set(['assets', 'references', 'scripts']);
|
|
12
|
+
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
13
|
+
const GITHUB_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/u;
|
|
14
|
+
function normalizeRef(ref) {
|
|
15
|
+
return ref?.trim() || DEFAULT_GITHUB_REF;
|
|
16
|
+
}
|
|
17
|
+
function toPosixPath(value) {
|
|
18
|
+
return value.replace(/\\/gu, '/').replace(/^\/+/u, '').replace(/\/+$/u, '');
|
|
19
|
+
}
|
|
20
|
+
function encodeGitHubPath(value) {
|
|
21
|
+
return toPosixPath(value)
|
|
22
|
+
.split('/')
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
.map((segment) => encodeURIComponent(segment))
|
|
25
|
+
.join('/');
|
|
26
|
+
}
|
|
27
|
+
function validateGitHubName(value, label) {
|
|
28
|
+
if (!value || !GITHUB_NAME_PATTERN.test(value) || value === '.' || value === '..') {
|
|
29
|
+
throw new Error(`Invalid GitHub ${label}: ${value}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function validateRelativeImportPath(value) {
|
|
33
|
+
const normalized = toPosixPath(value);
|
|
34
|
+
if (!normalized || normalized.includes('\0')) {
|
|
35
|
+
throw new Error(`Invalid external skill path: ${value}`);
|
|
36
|
+
}
|
|
37
|
+
const segments = normalized.split('/');
|
|
38
|
+
if (segments.some((segment) => segment === '.' || segment === '..' || segment.length === 0)) {
|
|
39
|
+
throw new Error(`External skill path must not contain traversal segments: ${value}`);
|
|
40
|
+
}
|
|
41
|
+
return normalized;
|
|
42
|
+
}
|
|
43
|
+
function skillDirectoryPathForBlobPath(blobPath) {
|
|
44
|
+
const normalized = validateRelativeImportPath(blobPath);
|
|
45
|
+
if (!normalized.endsWith('SKILL.md')) {
|
|
46
|
+
throw new Error('Blob and raw GitHub URLs must point to a SKILL.md file.');
|
|
47
|
+
}
|
|
48
|
+
return normalized.split('/').slice(0, -1).join('/');
|
|
49
|
+
}
|
|
50
|
+
function parseGitHubUrl(inputUrl, refOverride) {
|
|
51
|
+
let url;
|
|
52
|
+
try {
|
|
53
|
+
url = new URL(inputUrl);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new Error(`Unsupported external skill URL: ${inputUrl}`);
|
|
57
|
+
}
|
|
58
|
+
if (url.protocol !== 'https:') {
|
|
59
|
+
throw new Error('External skill imports require an HTTPS GitHub URL.');
|
|
60
|
+
}
|
|
61
|
+
const pathParts = url.pathname.split('/').filter(Boolean);
|
|
62
|
+
if (url.hostname === 'github.com') {
|
|
63
|
+
const [owner, repo, mode, ref, ...rest] = pathParts;
|
|
64
|
+
const ownerName = owner ?? '';
|
|
65
|
+
const repoName = repo ?? '';
|
|
66
|
+
validateGitHubName(ownerName, 'owner');
|
|
67
|
+
validateGitHubName(repoName, 'repo');
|
|
68
|
+
if (!mode) {
|
|
69
|
+
const selectedRef = normalizeRef(refOverride);
|
|
70
|
+
return {
|
|
71
|
+
host: 'github.com',
|
|
72
|
+
owner: ownerName,
|
|
73
|
+
repo: repoName,
|
|
74
|
+
ref: selectedRef,
|
|
75
|
+
skillPath: '',
|
|
76
|
+
sourceUrl: `https://github.com/${ownerName}/${repoName}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if ((mode === 'tree' || mode === 'blob') && ref) {
|
|
80
|
+
const selectedRef = normalizeRef(refOverride ?? ref);
|
|
81
|
+
const sourcePath = rest.join('/');
|
|
82
|
+
const skillPath = mode === 'blob' ? skillDirectoryPathForBlobPath(sourcePath) : validateRelativeImportPath(sourcePath);
|
|
83
|
+
return {
|
|
84
|
+
host: 'github.com',
|
|
85
|
+
owner: ownerName,
|
|
86
|
+
repo: repoName,
|
|
87
|
+
ref: selectedRef,
|
|
88
|
+
skillPath,
|
|
89
|
+
sourceUrl: `https://github.com/${ownerName}/${repoName}/${mode}/${ref}/${sourcePath}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (url.hostname === 'raw.githubusercontent.com') {
|
|
94
|
+
const [owner, repo, ref, ...rest] = pathParts;
|
|
95
|
+
const ownerName = owner ?? '';
|
|
96
|
+
const repoName = repo ?? '';
|
|
97
|
+
validateGitHubName(ownerName, 'owner');
|
|
98
|
+
validateGitHubName(repoName, 'repo');
|
|
99
|
+
if (!ref) {
|
|
100
|
+
throw new Error('Raw GitHub URLs must include a ref.');
|
|
101
|
+
}
|
|
102
|
+
const rawPath = rest.join('/');
|
|
103
|
+
const selectedRef = normalizeRef(refOverride ?? ref);
|
|
104
|
+
return {
|
|
105
|
+
host: 'raw.githubusercontent.com',
|
|
106
|
+
owner: ownerName,
|
|
107
|
+
repo: repoName,
|
|
108
|
+
ref: selectedRef,
|
|
109
|
+
skillPath: skillDirectoryPathForBlobPath(rawPath),
|
|
110
|
+
sourceUrl: `https://raw.githubusercontent.com/${ownerName}/${repoName}/${ref}/${rawPath}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
throw new Error('Only github.com and raw.githubusercontent.com skill URLs are supported.');
|
|
114
|
+
}
|
|
115
|
+
function externalSkillSourceFromParsed(inputUrl, parsed) {
|
|
116
|
+
return {
|
|
117
|
+
input_url: inputUrl,
|
|
118
|
+
host: parsed.host,
|
|
119
|
+
owner: parsed.owner,
|
|
120
|
+
repo: parsed.repo,
|
|
121
|
+
ref: parsed.ref,
|
|
122
|
+
skill_path: parsed.skillPath,
|
|
123
|
+
source_url: parsed.sourceUrl,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function sanitizeSkillName(value) {
|
|
127
|
+
return value
|
|
128
|
+
.toLowerCase()
|
|
129
|
+
.replace(/[^a-z0-9]+/gu, '-')
|
|
130
|
+
.replace(/^-+|-+$/gu, '')
|
|
131
|
+
.replace(/-{2,}/gu, '-');
|
|
132
|
+
}
|
|
133
|
+
function readFrontmatterScalar(content, key) {
|
|
134
|
+
if (!content.startsWith('---')) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const firstLineEnd = content.indexOf('\n');
|
|
138
|
+
const end = firstLineEnd >= 0 ? content.indexOf('\n---', firstLineEnd + 1) : -1;
|
|
139
|
+
if (firstLineEnd < 0 || end < 0) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
for (const line of content.slice(firstLineEnd + 1, end).split(/\r?\n/u)) {
|
|
143
|
+
const match = /^([a-zA-Z0-9_]+):\s*(.*)$/u.exec(line);
|
|
144
|
+
if (match?.[1] === key) {
|
|
145
|
+
return match[2].trim().replace(/^["']|["']$/gu, '') || null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
function targetNameForSkill(files, parsed, nameOverride) {
|
|
151
|
+
const candidate = nameOverride?.trim()
|
|
152
|
+
?? readFrontmatterScalar(files.find((file) => file.relativePath === 'SKILL.md')?.content ?? '', 'name')
|
|
153
|
+
?? parsed.skillPath.split('/').filter(Boolean).pop()
|
|
154
|
+
?? parsed.repo;
|
|
155
|
+
const sanitized = sanitizeSkillName(candidate);
|
|
156
|
+
if (!SLUG_PATTERN.test(sanitized)) {
|
|
157
|
+
throw new Error(`External skill name cannot be converted to a safe slug: ${candidate}`);
|
|
158
|
+
}
|
|
159
|
+
return sanitized;
|
|
160
|
+
}
|
|
161
|
+
function kindForRelativePath(relativePath) {
|
|
162
|
+
if (relativePath === 'SKILL.md') {
|
|
163
|
+
return 'skill';
|
|
164
|
+
}
|
|
165
|
+
const [topLevel] = relativePath.split('/');
|
|
166
|
+
if (topLevel === 'scripts') {
|
|
167
|
+
return 'script';
|
|
168
|
+
}
|
|
169
|
+
if (topLevel === 'assets') {
|
|
170
|
+
return 'asset';
|
|
171
|
+
}
|
|
172
|
+
return 'reference';
|
|
173
|
+
}
|
|
174
|
+
function validateImportRelativeFilePath(relativePath) {
|
|
175
|
+
const normalized = validateRelativeImportPath(relativePath);
|
|
176
|
+
const [topLevel] = normalized.split('/');
|
|
177
|
+
if (normalized === 'SKILL.md') {
|
|
178
|
+
return normalized;
|
|
179
|
+
}
|
|
180
|
+
if (!ALLOWED_SUPPORT_DIRECTORIES.has(topLevel)) {
|
|
181
|
+
throw new Error(`External skill import rejected unsupported file path: ${relativePath}`);
|
|
182
|
+
}
|
|
183
|
+
return normalized;
|
|
184
|
+
}
|
|
185
|
+
function normalizeTextContent(content) {
|
|
186
|
+
return content.replace(/\r\n?/gu, '\n');
|
|
187
|
+
}
|
|
188
|
+
function hashContent(content) {
|
|
189
|
+
return `sha256:${createHash('sha256').update(content).digest('hex')}`;
|
|
190
|
+
}
|
|
191
|
+
function isGitHubNotFoundError(error) {
|
|
192
|
+
return error instanceof Error && /\(404\)/u.test(error.message);
|
|
193
|
+
}
|
|
194
|
+
async function readResponseText(response, url) {
|
|
195
|
+
if (!response.ok) {
|
|
196
|
+
throw new Error(`GitHub request failed (${response.status}) for ${url}`);
|
|
197
|
+
}
|
|
198
|
+
const text = await response.text();
|
|
199
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_FILE_BYTES) {
|
|
200
|
+
throw new Error(`External skill file exceeds ${MAX_FILE_BYTES} bytes: ${url}`);
|
|
201
|
+
}
|
|
202
|
+
return normalizeTextContent(text);
|
|
203
|
+
}
|
|
204
|
+
async function fetchJson(fetchImpl, url) {
|
|
205
|
+
const response = await fetchImpl(url, {
|
|
206
|
+
headers: {
|
|
207
|
+
accept: 'application/vnd.github+json',
|
|
208
|
+
'user-agent': 'mustflow-external-skill-import',
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
if (!response.ok) {
|
|
212
|
+
throw new Error(`GitHub request failed (${response.status}) for ${url}`);
|
|
213
|
+
}
|
|
214
|
+
return response.json();
|
|
215
|
+
}
|
|
216
|
+
function apiContentsUrl(parsed, relativePath) {
|
|
217
|
+
const fullPath = [parsed.skillPath, relativePath].filter(Boolean).join('/');
|
|
218
|
+
const encodedPath = encodeGitHubPath(fullPath);
|
|
219
|
+
const pathSuffix = encodedPath ? `/${encodedPath}` : '';
|
|
220
|
+
return `https://api.github.com/repos/${parsed.owner}/${parsed.repo}/contents${pathSuffix}?ref=${encodeURIComponent(parsed.ref)}`;
|
|
221
|
+
}
|
|
222
|
+
function decodeGitHubFileContent(entry) {
|
|
223
|
+
if (entry.encoding !== 'base64' || typeof entry.content !== 'string') {
|
|
224
|
+
throw new Error(`GitHub content API response did not include base64 content for ${entry.path}`);
|
|
225
|
+
}
|
|
226
|
+
return normalizeTextContent(Buffer.from(entry.content.replace(/\s+/gu, ''), 'base64').toString('utf8'));
|
|
227
|
+
}
|
|
228
|
+
function assertGitHubContentEntry(value) {
|
|
229
|
+
if (!value || typeof value !== 'object') {
|
|
230
|
+
throw new Error('Unexpected GitHub contents API response.');
|
|
231
|
+
}
|
|
232
|
+
const record = value;
|
|
233
|
+
if (typeof record.type !== 'string' || typeof record.name !== 'string' || typeof record.path !== 'string') {
|
|
234
|
+
throw new Error('Unexpected GitHub contents API entry shape.');
|
|
235
|
+
}
|
|
236
|
+
return record;
|
|
237
|
+
}
|
|
238
|
+
async function loadGitHubFile(fetchImpl, parsed, relativePath) {
|
|
239
|
+
const normalizedRelativePath = validateImportRelativeFilePath(relativePath);
|
|
240
|
+
const apiUrl = apiContentsUrl(parsed, normalizedRelativePath);
|
|
241
|
+
const entry = assertGitHubContentEntry(await fetchJson(fetchImpl, apiUrl));
|
|
242
|
+
if (entry.type !== 'file') {
|
|
243
|
+
throw new Error(`Expected a file in GitHub contents API response: ${normalizedRelativePath}`);
|
|
244
|
+
}
|
|
245
|
+
const content = entry.download_url
|
|
246
|
+
? await readResponseText(await fetchImpl(entry.download_url), entry.download_url)
|
|
247
|
+
: decodeGitHubFileContent(entry);
|
|
248
|
+
return {
|
|
249
|
+
relativePath: normalizedRelativePath,
|
|
250
|
+
kind: kindForRelativePath(normalizedRelativePath),
|
|
251
|
+
content,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
async function listGitHubDirectory(fetchImpl, parsed, relativePath) {
|
|
255
|
+
const apiUrl = apiContentsUrl(parsed, relativePath);
|
|
256
|
+
const value = await fetchJson(fetchImpl, apiUrl);
|
|
257
|
+
if (!Array.isArray(value)) {
|
|
258
|
+
throw new Error(`Expected a directory in GitHub contents API response: ${relativePath || '.'}`);
|
|
259
|
+
}
|
|
260
|
+
return value.map(assertGitHubContentEntry);
|
|
261
|
+
}
|
|
262
|
+
async function loadSupportDirectory(fetchImpl, parsed, directory) {
|
|
263
|
+
let entries;
|
|
264
|
+
try {
|
|
265
|
+
entries = await listGitHubDirectory(fetchImpl, parsed, directory);
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
if (isGitHubNotFoundError(error)) {
|
|
269
|
+
return [];
|
|
270
|
+
}
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
const files = [];
|
|
274
|
+
const pending = entries
|
|
275
|
+
.filter((entry) => entry.type === 'file')
|
|
276
|
+
.map((entry) => `${directory}/${entry.name}`)
|
|
277
|
+
.sort((left, right) => left.localeCompare(right));
|
|
278
|
+
for (const relativePath of pending) {
|
|
279
|
+
files.push(await loadGitHubFile(fetchImpl, parsed, relativePath));
|
|
280
|
+
}
|
|
281
|
+
return files;
|
|
282
|
+
}
|
|
283
|
+
async function loadExternalSkillFiles(fetchImpl, parsed) {
|
|
284
|
+
const files = [
|
|
285
|
+
await loadGitHubFile(fetchImpl, parsed, 'SKILL.md'),
|
|
286
|
+
...(await loadSupportDirectory(fetchImpl, parsed, 'assets')),
|
|
287
|
+
...(await loadSupportDirectory(fetchImpl, parsed, 'references')),
|
|
288
|
+
...(await loadSupportDirectory(fetchImpl, parsed, 'scripts')),
|
|
289
|
+
].sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
290
|
+
if (files.length > MAX_IMPORTED_FILES) {
|
|
291
|
+
throw new Error(`External skill import exceeds ${MAX_IMPORTED_FILES} files.`);
|
|
292
|
+
}
|
|
293
|
+
const totalBytes = files.reduce((total, file) => total + Buffer.byteLength(file.content, 'utf8'), 0);
|
|
294
|
+
if (totalBytes > MAX_TOTAL_BYTES) {
|
|
295
|
+
throw new Error(`External skill import exceeds ${MAX_TOTAL_BYTES} bytes.`);
|
|
296
|
+
}
|
|
297
|
+
return files;
|
|
298
|
+
}
|
|
299
|
+
function fileReports(files) {
|
|
300
|
+
return files.map((file) => ({
|
|
301
|
+
relative_path: file.relativePath,
|
|
302
|
+
kind: file.kind,
|
|
303
|
+
bytes: Buffer.byteLength(file.content, 'utf8'),
|
|
304
|
+
sha256: hashContent(file.content),
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
function createTarget(skillName) {
|
|
308
|
+
const skillDir = `${EXTERNAL_SKILL_ROOT}/${skillName}`;
|
|
309
|
+
return {
|
|
310
|
+
root: EXTERNAL_SKILL_ROOT,
|
|
311
|
+
skill_name: skillName,
|
|
312
|
+
skill_dir: skillDir,
|
|
313
|
+
provenance_path: `${skillDir}/${PROVENANCE_FILE}`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function writeImportedSkillFiles(projectRoot, target, source, files, fileReport, warnings) {
|
|
317
|
+
const skillPath = path.join(projectRoot, ...target.skill_dir.split('/'), 'SKILL.md');
|
|
318
|
+
if (existsSync(skillPath)) {
|
|
319
|
+
throw new Error(`External skill already exists: ${target.skill_dir}`);
|
|
320
|
+
}
|
|
321
|
+
ensureFileTargetInsideWithoutSymlinks(projectRoot, skillPath, { allowMissingLeaf: true });
|
|
322
|
+
for (const file of files) {
|
|
323
|
+
writeUtf8FileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, ...target.skill_dir.split('/'), ...file.relativePath.split('/')), file.content);
|
|
324
|
+
}
|
|
325
|
+
writeJsonFileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, ...target.provenance_path.split('/')), {
|
|
326
|
+
schema_version: '1',
|
|
327
|
+
kind: 'external_skill_source',
|
|
328
|
+
source,
|
|
329
|
+
files: fileReport,
|
|
330
|
+
warnings,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
function rejectionReport(mode, issue) {
|
|
334
|
+
return {
|
|
335
|
+
schema_version: '1',
|
|
336
|
+
kind: 'skill_import_report',
|
|
337
|
+
command: 'skill',
|
|
338
|
+
action: 'import',
|
|
339
|
+
ok: false,
|
|
340
|
+
mode,
|
|
341
|
+
status: 'rejected',
|
|
342
|
+
source: null,
|
|
343
|
+
target: null,
|
|
344
|
+
files: [],
|
|
345
|
+
warnings: [],
|
|
346
|
+
issues: [issue],
|
|
347
|
+
wrote_files: false,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
export async function createExternalSkillImportReport(projectRoot, inputUrl, options) {
|
|
351
|
+
const mode = options.mode;
|
|
352
|
+
try {
|
|
353
|
+
const parsed = parseGitHubUrl(inputUrl, options.ref);
|
|
354
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
355
|
+
if (typeof fetchImpl !== 'function') {
|
|
356
|
+
throw new Error('This runtime does not provide fetch.');
|
|
357
|
+
}
|
|
358
|
+
const files = await loadExternalSkillFiles(fetchImpl, parsed);
|
|
359
|
+
const skillName = targetNameForSkill(files, parsed, options.name);
|
|
360
|
+
const target = createTarget(skillName);
|
|
361
|
+
const source = externalSkillSourceFromParsed(inputUrl, parsed);
|
|
362
|
+
const reports = fileReports(files);
|
|
363
|
+
const warnings = [
|
|
364
|
+
...(reports.some((file) => file.kind === 'script')
|
|
365
|
+
? ['Imported scripts are inert reference files; mustflow does not grant command authority for external scripts.']
|
|
366
|
+
: []),
|
|
367
|
+
'External skills are untrusted until the agent reads and evaluates the selected SKILL.md.',
|
|
368
|
+
];
|
|
369
|
+
if (mode === 'install') {
|
|
370
|
+
writeImportedSkillFiles(projectRoot, target, source, files, reports, warnings);
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
schema_version: '1',
|
|
374
|
+
kind: 'skill_import_report',
|
|
375
|
+
command: 'skill',
|
|
376
|
+
action: 'import',
|
|
377
|
+
ok: true,
|
|
378
|
+
mode,
|
|
379
|
+
status: mode === 'install' ? 'installed' : 'preview',
|
|
380
|
+
source,
|
|
381
|
+
target,
|
|
382
|
+
files: reports,
|
|
383
|
+
warnings,
|
|
384
|
+
issues: [],
|
|
385
|
+
wrote_files: mode === 'install',
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
return rejectionReport(mode, error instanceof Error ? error.message : String(error));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
@@ -158,6 +158,10 @@ function indexedFilesMatch(database, currentFiles) {
|
|
|
158
158
|
}
|
|
159
159
|
return true;
|
|
160
160
|
}
|
|
161
|
+
const INDEXED_FILE_MTIME_TOLERANCE_MS = 1;
|
|
162
|
+
function indexedFileMtimeMsEqual(storedMtimeMs, currentMtimeMs) {
|
|
163
|
+
return storedMtimeMs !== null && Math.abs(storedMtimeMs - currentMtimeMs) <= INDEXED_FILE_MTIME_TOLERANCE_MS;
|
|
164
|
+
}
|
|
161
165
|
function indexedFileMetadataMatch(database, currentFiles) {
|
|
162
166
|
const rows = queryRows(database, 'SELECT path, source_scope, size_bytes, mtime_ms, parser_version FROM indexed_files ORDER BY path');
|
|
163
167
|
if (rows.length !== currentFiles.length) {
|
|
@@ -172,7 +176,7 @@ function indexedFileMetadataMatch(database, currentFiles) {
|
|
|
172
176
|
}
|
|
173
177
|
if (normalizeIndexedFileSourceScope(toSearchString(row.source_scope)) !== current.sourceScope ||
|
|
174
178
|
toNullableNumber(row.size_bytes) !== current.sizeBytes ||
|
|
175
|
-
toNullableNumber(row.mtime_ms)
|
|
179
|
+
!indexedFileMtimeMsEqual(toNullableNumber(row.mtime_ms), current.mtimeMs) ||
|
|
176
180
|
toSearchString(row.parser_version) !== LOCAL_INDEX_PARSER_VERSION) {
|
|
177
181
|
return false;
|
|
178
182
|
}
|
|
@@ -566,6 +566,22 @@ const PUBLIC_JSON_SCHEMA_CONTRACTS = [
|
|
|
566
566
|
'--json',
|
|
567
567
|
],
|
|
568
568
|
},
|
|
569
|
+
{
|
|
570
|
+
id: 'skill-import-report',
|
|
571
|
+
schemaFile: 'skill-import-report.schema.json',
|
|
572
|
+
producer: 'mf skill import <github-url> --json',
|
|
573
|
+
packaged: true,
|
|
574
|
+
documented: true,
|
|
575
|
+
installedCommand: [
|
|
576
|
+
'mf',
|
|
577
|
+
'skill',
|
|
578
|
+
'import',
|
|
579
|
+
'https://github.com/example/agent-skills/blob/main/security-review/SKILL.md',
|
|
580
|
+
'--dry-run',
|
|
581
|
+
'--json',
|
|
582
|
+
],
|
|
583
|
+
expectedExitCodes: [0, 1],
|
|
584
|
+
},
|
|
569
585
|
{
|
|
570
586
|
id: 'route-fixture',
|
|
571
587
|
schemaFile: 'route-fixture.schema.json',
|