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.
Files changed (22) hide show
  1. package/dist/cli/commands/skill.js +76 -2
  2. package/dist/cli/lib/external-skill-import.js +391 -0
  3. package/dist/cli/lib/local-index/index.js +5 -1
  4. package/dist/core/public-json-contracts.js +16 -0
  5. package/dist/core/skill-route-resolution.js +54 -6
  6. package/package.json +1 -1
  7. package/schemas/README.md +3 -0
  8. package/schemas/skill-import-report.schema.json +97 -0
  9. package/templates/default/i18n.toml +36 -6
  10. package/templates/default/locales/en/.mustflow/skills/INDEX.md +22 -2
  11. package/templates/default/locales/en/.mustflow/skills/c-code-change/SKILL.md +371 -0
  12. package/templates/default/locales/en/.mustflow/skills/clarifying-question-gate/SKILL.md +53 -14
  13. package/templates/default/locales/en/.mustflow/skills/completion-evidence-gate/SKILL.md +15 -3
  14. package/templates/default/locales/en/.mustflow/skills/css-code-change/SKILL.md +74 -24
  15. package/templates/default/locales/en/.mustflow/skills/docs-prose-review/SKILL.md +36 -10
  16. package/templates/default/locales/en/.mustflow/skills/github-contribution-quality-gate/SKILL.md +27 -3
  17. package/templates/default/locales/en/.mustflow/skills/html-code-change/SKILL.md +37 -21
  18. package/templates/default/locales/en/.mustflow/skills/react-code-change/SKILL.md +278 -0
  19. package/templates/default/locales/en/.mustflow/skills/routes.toml +24 -0
  20. package/templates/default/locales/en/.mustflow/skills/shell-code-change/SKILL.md +279 -0
  21. package/templates/default/locales/en/.mustflow/skills/structured-config-change/SKILL.md +170 -0
  22. 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
- export function runSkill(args, reporter, lang = 'en') {
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) !== current.mtimeMs ||
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',