mustflow 2.112.14 → 2.114.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/README.md +2 -0
- package/dist/cli/commands/skill.js +90 -4
- package/dist/cli/lib/command-registry.js +1 -1
- package/dist/cli/lib/external-skill-import.js +649 -5
- package/dist/core/public-json-contracts.js +9 -0
- package/package.json +1 -1
- package/schemas/README.md +4 -0
- package/schemas/skill-import-report.schema.json +75 -0
- package/schemas/skill-update-report.schema.json +256 -0
- package/templates/default/i18n.toml +1 -1
- package/templates/default/locales/en/.mustflow/skills/security-privacy-review/SKILL.md +4 -1
- package/templates/default/manifest.toml +1 -1
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
import { existsSync, renameSync, rmSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, renameSync, rmSync } from 'node:fs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { readCommandContract, readCommandContractIncludePaths, } from '../../core/config-loading.js';
|
|
5
|
+
import { ensureFileTargetInsideWithoutSymlinks, readUtf8FileInsideWithoutSymlinks, writeJsonFileInsideWithoutSymlinks, writeUtf8FileInsideWithoutSymlinks, } from '../../core/safe-filesystem.js';
|
|
5
6
|
const EXTERNAL_SKILL_ROOT = '.mustflow/external-skills';
|
|
6
7
|
const PROVENANCE_FILE = 'mustflow-skill-source.json';
|
|
8
|
+
const COMMANDS_CONFIG_PATH = '.mustflow/config/commands.toml';
|
|
9
|
+
const COMMAND_FRAGMENT_DIRECTORY = '.mustflow/config/commands';
|
|
10
|
+
const EXTERNAL_SKILL_UPDATE_CHECK_STATE_PATH = '.mustflow/state/external-skills/update-check.json';
|
|
7
11
|
const DEFAULT_GITHUB_REF = 'HEAD';
|
|
8
12
|
const MAX_IMPORTED_FILES = 40;
|
|
9
13
|
const MAX_TOTAL_BYTES = 512 * 1024;
|
|
10
14
|
const MAX_FILE_BYTES = 256 * 1024;
|
|
15
|
+
const UPDATE_CHECK_STALE_AFTER_DAYS = 7;
|
|
16
|
+
const UPDATE_CHECK_STALE_AFTER_MS = UPDATE_CHECK_STALE_AFTER_DAYS * 24 * 60 * 60 * 1000;
|
|
11
17
|
const ALLOWED_SUPPORT_DIRECTORIES = new Set(['assets', 'references', 'scripts']);
|
|
12
18
|
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
13
19
|
const GITHUB_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/u;
|
|
@@ -130,6 +136,9 @@ function sanitizeSkillName(value) {
|
|
|
130
136
|
.replace(/^-+|-+$/gu, '')
|
|
131
137
|
.replace(/-{2,}/gu, '-');
|
|
132
138
|
}
|
|
139
|
+
function commandSafeSegment(value) {
|
|
140
|
+
return sanitizeSkillName(value).replace(/-/gu, '_');
|
|
141
|
+
}
|
|
133
142
|
function readFrontmatterScalar(content, key) {
|
|
134
143
|
if (!content.startsWith('---')) {
|
|
135
144
|
return null;
|
|
@@ -359,7 +368,391 @@ function createTarget(skillName) {
|
|
|
359
368
|
provenance_path: `${skillDir}/${PROVENANCE_FILE}`,
|
|
360
369
|
};
|
|
361
370
|
}
|
|
362
|
-
function
|
|
371
|
+
function sourceToParsed(source) {
|
|
372
|
+
return {
|
|
373
|
+
host: source.host,
|
|
374
|
+
owner: source.owner,
|
|
375
|
+
repo: source.repo,
|
|
376
|
+
ref: source.ref,
|
|
377
|
+
skillPath: source.skill_path,
|
|
378
|
+
sourceUrl: source.source_url,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
function provenancePathForSkill(skillName) {
|
|
382
|
+
return `${EXTERNAL_SKILL_ROOT}/${skillName}/${PROVENANCE_FILE}`;
|
|
383
|
+
}
|
|
384
|
+
function readJsonFile(projectRoot, relativePath, maxBytes = 512 * 1024) {
|
|
385
|
+
const content = readUtf8FileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, ...relativePath.split('/')), { maxBytes });
|
|
386
|
+
return JSON.parse(content);
|
|
387
|
+
}
|
|
388
|
+
function assertExternalSkillSource(value, skillName) {
|
|
389
|
+
if (!value || typeof value !== 'object') {
|
|
390
|
+
throw new Error(`External skill provenance is not an object: ${skillName}`);
|
|
391
|
+
}
|
|
392
|
+
const record = value;
|
|
393
|
+
if (record.schema_version !== '1' || record.kind !== 'external_skill_source') {
|
|
394
|
+
throw new Error(`External skill provenance has an unsupported shape: ${skillName}`);
|
|
395
|
+
}
|
|
396
|
+
if (!record.source || typeof record.source !== 'object') {
|
|
397
|
+
throw new Error(`External skill provenance is missing source metadata: ${skillName}`);
|
|
398
|
+
}
|
|
399
|
+
if (!Array.isArray(record.files)) {
|
|
400
|
+
throw new Error(`External skill provenance is missing file hashes: ${skillName}`);
|
|
401
|
+
}
|
|
402
|
+
return record;
|
|
403
|
+
}
|
|
404
|
+
function installedExternalSkillNames(projectRoot) {
|
|
405
|
+
const rootPath = path.join(projectRoot, ...EXTERNAL_SKILL_ROOT.split('/'));
|
|
406
|
+
if (!existsSync(rootPath)) {
|
|
407
|
+
return [];
|
|
408
|
+
}
|
|
409
|
+
return readdirSync(rootPath, { withFileTypes: true })
|
|
410
|
+
.filter((entry) => entry.isDirectory())
|
|
411
|
+
.map((entry) => entry.name)
|
|
412
|
+
.filter((name) => SLUG_PATTERN.test(name) && existsSync(path.join(rootPath, name, PROVENANCE_FILE)))
|
|
413
|
+
.sort((left, right) => left.localeCompare(right));
|
|
414
|
+
}
|
|
415
|
+
function readExternalSkillProvenance(projectRoot, skillName) {
|
|
416
|
+
return assertExternalSkillSource(readJsonFile(projectRoot, provenancePathForSkill(skillName)), skillName);
|
|
417
|
+
}
|
|
418
|
+
function readExternalSkillUpdateCheckStateFile(projectRoot) {
|
|
419
|
+
const statePath = path.join(projectRoot, ...EXTERNAL_SKILL_UPDATE_CHECK_STATE_PATH.split('/'));
|
|
420
|
+
if (!existsSync(statePath)) {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
try {
|
|
424
|
+
const value = readJsonFile(projectRoot, EXTERNAL_SKILL_UPDATE_CHECK_STATE_PATH, 64 * 1024);
|
|
425
|
+
if (!value || typeof value !== 'object') {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
const record = value;
|
|
429
|
+
if (record.schema_version !== '1' ||
|
|
430
|
+
record.kind !== 'external_skill_update_check_state' ||
|
|
431
|
+
typeof record.last_checked_at !== 'string' ||
|
|
432
|
+
Number.isNaN(Date.parse(record.last_checked_at))) {
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
schema_version: '1',
|
|
437
|
+
kind: 'external_skill_update_check_state',
|
|
438
|
+
last_checked_at: record.last_checked_at,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function createExternalSkillUpdateCheckState(projectRoot, now) {
|
|
446
|
+
const previous = readExternalSkillUpdateCheckStateFile(projectRoot);
|
|
447
|
+
const checkedAt = now.toISOString();
|
|
448
|
+
const nextCheckDueAt = new Date(now.getTime() + UPDATE_CHECK_STALE_AFTER_MS).toISOString();
|
|
449
|
+
const previousTime = previous ? Date.parse(previous.last_checked_at) : Number.NaN;
|
|
450
|
+
const hasInstalledExternalSkills = installedExternalSkillNames(projectRoot).length > 0;
|
|
451
|
+
const staleBeforeCommand = hasInstalledExternalSkills && (!previous ||
|
|
452
|
+
Number.isNaN(previousTime) ||
|
|
453
|
+
now.getTime() - previousTime >= UPDATE_CHECK_STALE_AFTER_MS);
|
|
454
|
+
return {
|
|
455
|
+
state_path: EXTERNAL_SKILL_UPDATE_CHECK_STATE_PATH,
|
|
456
|
+
stale_after_days: UPDATE_CHECK_STALE_AFTER_DAYS,
|
|
457
|
+
checked_at: checkedAt,
|
|
458
|
+
previous_checked_at: previous?.last_checked_at ?? null,
|
|
459
|
+
next_check_due_at: nextCheckDueAt,
|
|
460
|
+
stale_before_command: staleBeforeCommand,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
function writeExternalSkillUpdateCheckState(projectRoot, checkedAt) {
|
|
464
|
+
writeJsonFileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, ...EXTERNAL_SKILL_UPDATE_CHECK_STATE_PATH.split('/')), {
|
|
465
|
+
schema_version: '1',
|
|
466
|
+
kind: 'external_skill_update_check_state',
|
|
467
|
+
last_checked_at: checkedAt,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
export function createExternalSkillUpdateReminder(projectRoot, now = new Date()) {
|
|
471
|
+
const hasInstalledExternalSkills = installedExternalSkillNames(projectRoot).length > 0;
|
|
472
|
+
if (!hasInstalledExternalSkills) {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
const previous = readExternalSkillUpdateCheckStateFile(projectRoot);
|
|
476
|
+
const previousTime = previous ? Date.parse(previous.last_checked_at) : Number.NaN;
|
|
477
|
+
if (previous && !Number.isNaN(previousTime) && now.getTime() - previousTime < UPDATE_CHECK_STALE_AFTER_MS) {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
return `External skill update check is stale; run mf skill outdated --json or mf skill update --all --dry-run --json. Last checked: ${previous?.last_checked_at ?? 'never'}.`;
|
|
481
|
+
}
|
|
482
|
+
function readInstalledRelativeFilePaths(projectRoot, target) {
|
|
483
|
+
const targetPath = path.join(projectRoot, ...target.skill_dir.split('/'));
|
|
484
|
+
const pending = [''];
|
|
485
|
+
const files = [];
|
|
486
|
+
const stack = [...pending];
|
|
487
|
+
while (stack.length > 0) {
|
|
488
|
+
const relativeDirectory = stack.pop() ?? '';
|
|
489
|
+
const absoluteDirectory = relativeDirectory
|
|
490
|
+
? path.join(targetPath, ...relativeDirectory.split('/'))
|
|
491
|
+
: targetPath;
|
|
492
|
+
const entries = readdirSync(absoluteDirectory, { withFileTypes: true })
|
|
493
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
494
|
+
for (const entry of entries) {
|
|
495
|
+
const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name;
|
|
496
|
+
if (relativePath === PROVENANCE_FILE) {
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (entry.isDirectory()) {
|
|
500
|
+
stack.push(relativePath);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
files.push(relativePath);
|
|
504
|
+
if (files.length > MAX_IMPORTED_FILES) {
|
|
505
|
+
throw new Error(`External skill ${target.skill_name} exceeds ${MAX_IMPORTED_FILES} installed files.`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return files.sort((left, right) => left.localeCompare(right));
|
|
510
|
+
}
|
|
511
|
+
function readInstalledFileReports(projectRoot, target, provenanceFiles) {
|
|
512
|
+
return provenanceFiles.map((file) => {
|
|
513
|
+
const relativePath = `${target.skill_dir}/${file.relative_path}`;
|
|
514
|
+
const content = readUtf8FileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, ...relativePath.split('/')), { maxBytes: MAX_FILE_BYTES });
|
|
515
|
+
return {
|
|
516
|
+
relative_path: file.relative_path,
|
|
517
|
+
kind: file.kind,
|
|
518
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
519
|
+
sha256: hashContent(content),
|
|
520
|
+
};
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
function collectFileChanges(currentFiles, remoteFiles) {
|
|
524
|
+
const currentByPath = new Map(currentFiles.map((file) => [file.relative_path, file]));
|
|
525
|
+
const remoteByPath = new Map(remoteFiles.map((file) => [file.relative_path, file]));
|
|
526
|
+
const paths = [...new Set([...currentByPath.keys(), ...remoteByPath.keys()])].sort((left, right) => left.localeCompare(right));
|
|
527
|
+
const changes = [];
|
|
528
|
+
for (const relativePath of paths) {
|
|
529
|
+
const current = currentByPath.get(relativePath);
|
|
530
|
+
const remote = remoteByPath.get(relativePath);
|
|
531
|
+
if (!current && remote) {
|
|
532
|
+
changes.push({
|
|
533
|
+
relative_path: relativePath,
|
|
534
|
+
status: 'added',
|
|
535
|
+
current_sha256: null,
|
|
536
|
+
remote_sha256: remote.sha256,
|
|
537
|
+
});
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (current && !remote) {
|
|
541
|
+
changes.push({
|
|
542
|
+
relative_path: relativePath,
|
|
543
|
+
status: 'removed',
|
|
544
|
+
current_sha256: current.sha256,
|
|
545
|
+
remote_sha256: null,
|
|
546
|
+
});
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (current && remote && current.sha256 !== remote.sha256) {
|
|
550
|
+
changes.push({
|
|
551
|
+
relative_path: relativePath,
|
|
552
|
+
status: 'modified',
|
|
553
|
+
current_sha256: current.sha256,
|
|
554
|
+
remote_sha256: remote.sha256,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return changes;
|
|
559
|
+
}
|
|
560
|
+
function localDriftIssues(skillName, provenanceFiles, installedFiles, installedRelativeFilePaths) {
|
|
561
|
+
const provenanceByPath = new Map(provenanceFiles.map((file) => [file.relative_path, file]));
|
|
562
|
+
const provenancePaths = new Set(provenanceFiles.map((file) => file.relative_path));
|
|
563
|
+
const issues = [];
|
|
564
|
+
for (const installed of installedFiles) {
|
|
565
|
+
const provenance = provenanceByPath.get(installed.relative_path);
|
|
566
|
+
if (!provenance || provenance.sha256 !== installed.sha256) {
|
|
567
|
+
issues.push(`External skill ${skillName} has local file drift: ${installed.relative_path}`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
for (const relativePath of installedRelativeFilePaths) {
|
|
571
|
+
if (!provenancePaths.has(relativePath)) {
|
|
572
|
+
issues.push(`External skill ${skillName} has local file drift: ${relativePath}`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return issues;
|
|
576
|
+
}
|
|
577
|
+
function scriptNameForIntent(relativePath) {
|
|
578
|
+
const basename = path.posix.basename(relativePath);
|
|
579
|
+
const withoutExtension = basename.replace(/\.[^.]+$/u, '');
|
|
580
|
+
const sanitized = commandSafeSegment(withoutExtension);
|
|
581
|
+
if (!sanitized) {
|
|
582
|
+
throw new Error(`External script path cannot be converted to a safe intent name: ${relativePath}`);
|
|
583
|
+
}
|
|
584
|
+
return sanitized;
|
|
585
|
+
}
|
|
586
|
+
function trustedScriptArgv(skillName, scriptPath) {
|
|
587
|
+
const extension = path.posix.extname(scriptPath).toLowerCase();
|
|
588
|
+
const projectScriptPath = `${EXTERNAL_SKILL_ROOT}/${skillName}/${scriptPath}`;
|
|
589
|
+
if (['.js', '.mjs', '.cjs'].includes(extension)) {
|
|
590
|
+
return ['node', projectScriptPath];
|
|
591
|
+
}
|
|
592
|
+
if (['.ts', '.mts', '.cts'].includes(extension)) {
|
|
593
|
+
return ['bun', projectScriptPath];
|
|
594
|
+
}
|
|
595
|
+
if (extension === '.ps1') {
|
|
596
|
+
return ['pwsh', '-NoProfile', '-File', projectScriptPath];
|
|
597
|
+
}
|
|
598
|
+
if (extension === '.sh') {
|
|
599
|
+
return ['sh', projectScriptPath];
|
|
600
|
+
}
|
|
601
|
+
throw new Error(`External script import cannot create a trusted command intent for unsupported script type: ${scriptPath}`);
|
|
602
|
+
}
|
|
603
|
+
function createTrustedScriptIntent(skillName, scriptPath) {
|
|
604
|
+
return {
|
|
605
|
+
intent: `external_skill_${commandSafeSegment(skillName)}_${scriptNameForIntent(scriptPath)}`,
|
|
606
|
+
script_path: scriptPath,
|
|
607
|
+
argv: trustedScriptArgv(skillName, scriptPath),
|
|
608
|
+
run_policy: 'agent_allowed',
|
|
609
|
+
network: true,
|
|
610
|
+
destructive: true,
|
|
611
|
+
approval_required: ['network_access', 'destructive_command'],
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
function createScriptTrust(projectRoot, target, files, trustScripts, mode, existingTrust) {
|
|
615
|
+
const scripts = files.filter((file) => file.kind === 'script');
|
|
616
|
+
if (!trustScripts) {
|
|
617
|
+
return {
|
|
618
|
+
requested: false,
|
|
619
|
+
status: 'not_requested',
|
|
620
|
+
grants_command_authority: false,
|
|
621
|
+
command_contract_path: null,
|
|
622
|
+
include_entry: null,
|
|
623
|
+
fragment_path: null,
|
|
624
|
+
intents: [],
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
if (scripts.length === 0) {
|
|
628
|
+
return {
|
|
629
|
+
requested: true,
|
|
630
|
+
status: 'no_scripts',
|
|
631
|
+
grants_command_authority: false,
|
|
632
|
+
command_contract_path: null,
|
|
633
|
+
include_entry: null,
|
|
634
|
+
fragment_path: null,
|
|
635
|
+
intents: [],
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
const fragmentName = `external-skills-${target.skill_name}.toml`;
|
|
639
|
+
const includeEntry = `commands/${fragmentName}`;
|
|
640
|
+
const fragmentPath = `${COMMAND_FRAGMENT_DIRECTORY}/${fragmentName}`;
|
|
641
|
+
const intents = scripts.map((script) => createTrustedScriptIntent(target.skill_name, script.relative_path));
|
|
642
|
+
const duplicateIntent = findDuplicate(intents.map((intent) => intent.intent));
|
|
643
|
+
if (duplicateIntent) {
|
|
644
|
+
throw new Error(`External skill trusted script intents contain a duplicate name: ${duplicateIntent}`);
|
|
645
|
+
}
|
|
646
|
+
validateTrustedScriptCommandPlan(projectRoot, includeEntry, fragmentPath, intents, existingTrust);
|
|
647
|
+
return {
|
|
648
|
+
requested: true,
|
|
649
|
+
status: mode === 'install' ? 'trusted' : 'planned',
|
|
650
|
+
grants_command_authority: mode === 'install',
|
|
651
|
+
command_contract_path: COMMANDS_CONFIG_PATH,
|
|
652
|
+
include_entry: includeEntry,
|
|
653
|
+
fragment_path: fragmentPath,
|
|
654
|
+
intents,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
function findDuplicate(values) {
|
|
658
|
+
const seen = new Set();
|
|
659
|
+
for (const value of values) {
|
|
660
|
+
if (seen.has(value)) {
|
|
661
|
+
return value;
|
|
662
|
+
}
|
|
663
|
+
seen.add(value);
|
|
664
|
+
}
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
function validateTrustedScriptCommandPlan(projectRoot, includeEntry, fragmentPath, intents, existingTrust) {
|
|
668
|
+
const existingIncludes = new Set(readCommandContractIncludePaths(projectRoot).map((entry) => entry.replace(/^\.mustflow\/config\//u, '')));
|
|
669
|
+
const fragmentExists = existsSync(path.join(projectRoot, ...fragmentPath.split('/')));
|
|
670
|
+
const mayReuseExistingPlan = existingTrust?.grants_command_authority === true &&
|
|
671
|
+
existingTrust.include_entry === includeEntry &&
|
|
672
|
+
existingTrust.fragment_path === fragmentPath &&
|
|
673
|
+
existingIncludes.has(includeEntry) &&
|
|
674
|
+
fragmentExists;
|
|
675
|
+
const reusableIntentNames = new Set(mayReuseExistingPlan ? existingTrust.intents.map((intent) => intent.intent) : []);
|
|
676
|
+
if (existingIncludes.has(includeEntry) && !mayReuseExistingPlan) {
|
|
677
|
+
throw new Error(`External skill trusted script command include already exists: ${includeEntry}`);
|
|
678
|
+
}
|
|
679
|
+
if (fragmentExists && !mayReuseExistingPlan) {
|
|
680
|
+
throw new Error(`External skill trusted script command fragment already exists: ${fragmentPath}`);
|
|
681
|
+
}
|
|
682
|
+
const contract = readCommandContract(projectRoot);
|
|
683
|
+
for (const intent of intents) {
|
|
684
|
+
if (Object.prototype.hasOwnProperty.call(contract.intents, intent.intent) && !reusableIntentNames.has(intent.intent)) {
|
|
685
|
+
throw new Error(`External skill trusted script intent already exists in command contract: ${intent.intent}`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
function renderTomlString(value) {
|
|
690
|
+
return JSON.stringify(value);
|
|
691
|
+
}
|
|
692
|
+
function renderTomlStringArray(values) {
|
|
693
|
+
return `[${values.map(renderTomlString).join(', ')}]`;
|
|
694
|
+
}
|
|
695
|
+
function renderTrustedScriptCommandFragment(target, source, scriptTrust) {
|
|
696
|
+
const lines = [
|
|
697
|
+
`# Generated by mf skill import --trust-scripts for external skill ${JSON.stringify(target.skill_name)}.`,
|
|
698
|
+
`# Source: ${source.source_url}`,
|
|
699
|
+
'# These intents execute imported external script files. They are agent-runnable only through mf run,',
|
|
700
|
+
'# and remain gated by network_access and destructive_command approvals.',
|
|
701
|
+
'',
|
|
702
|
+
];
|
|
703
|
+
for (const intent of scriptTrust.intents) {
|
|
704
|
+
lines.push(`[intents.${intent.intent}]`, 'status = "configured"', 'lifecycle = "oneshot"', 'run_policy = "agent_allowed"', `description = ${renderTomlString(`Run trusted external skill script ${intent.script_path} from ${target.skill_name}.`)}`, `argv = ${renderTomlStringArray(intent.argv)}`, 'cwd = "."', 'timeout_seconds = 300', 'stdin = "closed"', 'success_exit_codes = [0]', 'writes = []', 'network = true', 'destructive = true', 'env_policy = "minimal"', '');
|
|
705
|
+
}
|
|
706
|
+
return lines.join('\n');
|
|
707
|
+
}
|
|
708
|
+
function updateCommandIncludeText(content, includeEntry) {
|
|
709
|
+
const normalizedContent = content.replace(/\r\n?/gu, '\n');
|
|
710
|
+
const lines = normalizedContent.split('\n');
|
|
711
|
+
const includeLineIndex = lines.findIndex((line) => /^\s*\[include\]\s*$/u.test(line));
|
|
712
|
+
const includeLine = ` ${renderTomlString(includeEntry)},`;
|
|
713
|
+
if (includeLineIndex < 0) {
|
|
714
|
+
const separator = normalizedContent.endsWith('\n') ? '' : '\n';
|
|
715
|
+
return `${normalizedContent}${separator}\n[include]\nfiles = [\n${includeLine}\n]\n`;
|
|
716
|
+
}
|
|
717
|
+
const nextSectionIndex = lines.findIndex((line, index) => index > includeLineIndex && /^\s*\[[^\]]+\]\s*$/u.test(line));
|
|
718
|
+
const sectionEnd = nextSectionIndex < 0 ? lines.length : nextSectionIndex;
|
|
719
|
+
const filesLineIndex = lines.findIndex((line, index) => index > includeLineIndex &&
|
|
720
|
+
index < sectionEnd &&
|
|
721
|
+
/^\s*files\s*=\s*\[/u.test(line));
|
|
722
|
+
if (filesLineIndex < 0) {
|
|
723
|
+
lines.splice(includeLineIndex + 1, 0, 'files = [', includeLine, ']');
|
|
724
|
+
return lines.join('\n');
|
|
725
|
+
}
|
|
726
|
+
if (lines[filesLineIndex].includes(']')) {
|
|
727
|
+
const existingValues = [...lines[filesLineIndex].matchAll(/"([^"]+)"/gu)].map((match) => match[1]);
|
|
728
|
+
const values = [...new Set([...existingValues, includeEntry])].sort((left, right) => left.localeCompare(right));
|
|
729
|
+
lines.splice(filesLineIndex, 1, 'files = [', ...values.map((entry) => ` ${renderTomlString(entry)},`), ']');
|
|
730
|
+
return lines.join('\n');
|
|
731
|
+
}
|
|
732
|
+
const closingLineIndex = lines.findIndex((line, index) => index > filesLineIndex && index < sectionEnd && /^\s*\]\s*$/u.test(line));
|
|
733
|
+
if (closingLineIndex < 0) {
|
|
734
|
+
throw new Error(`[include].files in ${COMMANDS_CONFIG_PATH} must be a TOML array`);
|
|
735
|
+
}
|
|
736
|
+
const existingValues = lines
|
|
737
|
+
.slice(filesLineIndex + 1, closingLineIndex)
|
|
738
|
+
.flatMap((line) => [...line.matchAll(/"([^"]+)"/gu)].map((match) => match[1]));
|
|
739
|
+
if (existingValues.includes(includeEntry)) {
|
|
740
|
+
return lines.join('\n');
|
|
741
|
+
}
|
|
742
|
+
lines.splice(closingLineIndex, 0, includeLine);
|
|
743
|
+
return lines.join('\n');
|
|
744
|
+
}
|
|
745
|
+
function writeTrustedScriptCommandContract(projectRoot, target, source, scriptTrust) {
|
|
746
|
+
if (scriptTrust.status !== 'trusted' || !scriptTrust.fragment_path || !scriptTrust.include_entry) {
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const fragmentContent = renderTrustedScriptCommandFragment(target, source, scriptTrust);
|
|
750
|
+
writeUtf8FileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, ...scriptTrust.fragment_path.split('/')), fragmentContent);
|
|
751
|
+
const commandsPath = path.join(projectRoot, ...COMMANDS_CONFIG_PATH.split('/'));
|
|
752
|
+
const commandsContent = readUtf8FileInsideWithoutSymlinks(projectRoot, commandsPath, { maxBytes: 256 * 1024 });
|
|
753
|
+
writeUtf8FileInsideWithoutSymlinks(projectRoot, commandsPath, updateCommandIncludeText(commandsContent, scriptTrust.include_entry));
|
|
754
|
+
}
|
|
755
|
+
function writeImportedSkillFiles(projectRoot, target, source, files, fileReport, warnings, scriptTrust) {
|
|
363
756
|
const targetPath = path.join(projectRoot, ...target.skill_dir.split('/'));
|
|
364
757
|
const skillPath = path.join(targetPath, 'SKILL.md');
|
|
365
758
|
if (existsSync(targetPath)) {
|
|
@@ -384,6 +777,7 @@ function writeImportedSkillFiles(projectRoot, target, source, files, fileReport,
|
|
|
384
777
|
kind: 'external_skill_source',
|
|
385
778
|
source,
|
|
386
779
|
files: fileReport,
|
|
780
|
+
script_trust: scriptTrust,
|
|
387
781
|
warnings,
|
|
388
782
|
});
|
|
389
783
|
renameSync(tempPath, targetPath);
|
|
@@ -393,6 +787,45 @@ function writeImportedSkillFiles(projectRoot, target, source, files, fileReport,
|
|
|
393
787
|
throw error;
|
|
394
788
|
}
|
|
395
789
|
}
|
|
790
|
+
function writeUpdatedSkillFiles(projectRoot, target, source, files, fileReport, warnings, scriptTrust) {
|
|
791
|
+
const targetPath = path.join(projectRoot, ...target.skill_dir.split('/'));
|
|
792
|
+
if (!existsSync(targetPath)) {
|
|
793
|
+
throw new Error(`External skill is not installed: ${target.skill_dir}`);
|
|
794
|
+
}
|
|
795
|
+
const timestamp = Date.now();
|
|
796
|
+
const tempSkillDir = `${EXTERNAL_SKILL_ROOT}/.${target.skill_name}.update-${process.pid}-${timestamp}`;
|
|
797
|
+
const backupSkillDir = `${EXTERNAL_SKILL_ROOT}/.${target.skill_name}.backup-${process.pid}-${timestamp}`;
|
|
798
|
+
const tempPath = path.join(projectRoot, ...tempSkillDir.split('/'));
|
|
799
|
+
const backupPath = path.join(projectRoot, ...backupSkillDir.split('/'));
|
|
800
|
+
const tempSkillPath = path.join(tempPath, 'SKILL.md');
|
|
801
|
+
ensureFileTargetInsideWithoutSymlinks(projectRoot, tempSkillPath, { allowMissingLeaf: true });
|
|
802
|
+
try {
|
|
803
|
+
for (const file of files) {
|
|
804
|
+
writeUtf8FileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, ...tempSkillDir.split('/'), ...file.relativePath.split('/')), file.content);
|
|
805
|
+
}
|
|
806
|
+
writeJsonFileInsideWithoutSymlinks(projectRoot, path.join(projectRoot, ...tempSkillDir.split('/'), PROVENANCE_FILE), {
|
|
807
|
+
schema_version: '1',
|
|
808
|
+
kind: 'external_skill_source',
|
|
809
|
+
source,
|
|
810
|
+
files: fileReport,
|
|
811
|
+
script_trust: scriptTrust,
|
|
812
|
+
warnings,
|
|
813
|
+
});
|
|
814
|
+
renameSync(targetPath, backupPath);
|
|
815
|
+
renameSync(tempPath, targetPath);
|
|
816
|
+
rmSync(backupPath, { recursive: true, force: true });
|
|
817
|
+
}
|
|
818
|
+
catch (error) {
|
|
819
|
+
rmSync(tempPath, { recursive: true, force: true });
|
|
820
|
+
if (!existsSync(targetPath) && existsSync(backupPath)) {
|
|
821
|
+
renameSync(backupPath, targetPath);
|
|
822
|
+
}
|
|
823
|
+
else {
|
|
824
|
+
rmSync(backupPath, { recursive: true, force: true });
|
|
825
|
+
}
|
|
826
|
+
throw error;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
396
829
|
function rejectionReport(mode, issue) {
|
|
397
830
|
return {
|
|
398
831
|
schema_version: '1',
|
|
@@ -410,6 +843,199 @@ function rejectionReport(mode, issue) {
|
|
|
410
843
|
wrote_files: false,
|
|
411
844
|
};
|
|
412
845
|
}
|
|
846
|
+
function rejectedUpdateItem(skillName, issue) {
|
|
847
|
+
return {
|
|
848
|
+
skill_name: skillName,
|
|
849
|
+
ok: false,
|
|
850
|
+
status: 'rejected',
|
|
851
|
+
source: null,
|
|
852
|
+
target: createTarget(skillName),
|
|
853
|
+
current_files: [],
|
|
854
|
+
remote_files: [],
|
|
855
|
+
changed_files: [],
|
|
856
|
+
warnings: [],
|
|
857
|
+
issues: [issue],
|
|
858
|
+
wrote_files: false,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
async function createExternalSkillUpdateItem(projectRoot, skillName, options) {
|
|
862
|
+
const target = createTarget(skillName);
|
|
863
|
+
try {
|
|
864
|
+
const provenance = readExternalSkillProvenance(projectRoot, skillName);
|
|
865
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
866
|
+
if (typeof fetchImpl !== 'function') {
|
|
867
|
+
throw new Error('This runtime does not provide fetch.');
|
|
868
|
+
}
|
|
869
|
+
const installedRelativeFilePaths = readInstalledRelativeFilePaths(projectRoot, target);
|
|
870
|
+
const installedFiles = readInstalledFileReports(projectRoot, target, provenance.files);
|
|
871
|
+
const driftIssues = localDriftIssues(skillName, provenance.files, installedFiles, installedRelativeFilePaths);
|
|
872
|
+
if (driftIssues.length > 0) {
|
|
873
|
+
return {
|
|
874
|
+
skill_name: skillName,
|
|
875
|
+
ok: false,
|
|
876
|
+
status: 'rejected',
|
|
877
|
+
source: provenance.source,
|
|
878
|
+
target,
|
|
879
|
+
current_files: installedFiles,
|
|
880
|
+
remote_files: [],
|
|
881
|
+
changed_files: [],
|
|
882
|
+
script_trust: provenance.script_trust,
|
|
883
|
+
warnings: [],
|
|
884
|
+
issues: driftIssues,
|
|
885
|
+
wrote_files: false,
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
const remoteSourceFiles = await loadExternalSkillFiles(fetchImpl, sourceToParsed(provenance.source));
|
|
889
|
+
const normalizedRemoteFiles = normalizeImportedSkillFiles(remoteSourceFiles);
|
|
890
|
+
const remoteReports = fileReports(normalizedRemoteFiles);
|
|
891
|
+
const changedFiles = collectFileChanges(provenance.files, remoteReports);
|
|
892
|
+
const mode = options.mode ?? (options.action === 'outdated' ? 'check' : 'install');
|
|
893
|
+
const trustScripts = options.action === 'update' && options.trustScripts === true;
|
|
894
|
+
const hasScriptFiles = remoteReports.some((file) => file.kind === 'script');
|
|
895
|
+
if (options.action === 'update' &&
|
|
896
|
+
mode === 'install' &&
|
|
897
|
+
hasScriptFiles &&
|
|
898
|
+
provenance.script_trust?.grants_command_authority === true &&
|
|
899
|
+
!trustScripts) {
|
|
900
|
+
return {
|
|
901
|
+
skill_name: skillName,
|
|
902
|
+
ok: false,
|
|
903
|
+
status: 'rejected',
|
|
904
|
+
source: provenance.source,
|
|
905
|
+
target,
|
|
906
|
+
current_files: installedFiles,
|
|
907
|
+
remote_files: remoteReports,
|
|
908
|
+
changed_files: changedFiles,
|
|
909
|
+
script_trust: provenance.script_trust,
|
|
910
|
+
warnings: [],
|
|
911
|
+
issues: [`External skill ${skillName} has trusted scripts; rerun update with --trust-scripts to refresh script files.`],
|
|
912
|
+
wrote_files: false,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
const scriptTrust = createScriptTrust(projectRoot, target, remoteReports, trustScripts, mode === 'install' ? 'install' : 'dry_run', provenance.script_trust);
|
|
916
|
+
const warnings = [
|
|
917
|
+
...(changedFiles.length === 0 ? ['External skill is already current.'] : []),
|
|
918
|
+
...(hasScriptFiles && scriptTrust.status !== 'trusted'
|
|
919
|
+
? ['Imported scripts are inert reference files; mustflow does not grant command authority for external scripts.']
|
|
920
|
+
: []),
|
|
921
|
+
...(scriptTrust.status === 'trusted'
|
|
922
|
+
? ['Imported scripts were trusted by request; mustflow created command-contract intents gated by network and destructive approvals.']
|
|
923
|
+
: []),
|
|
924
|
+
...(scriptTrust.status === 'planned'
|
|
925
|
+
? ['Imported scripts would be trusted by request during install; dry-run only reports the command-contract plan.']
|
|
926
|
+
: []),
|
|
927
|
+
'External skills are untrusted until the agent reads and evaluates the selected SKILL.md.',
|
|
928
|
+
];
|
|
929
|
+
if (options.action === 'update' && mode === 'install' && changedFiles.length > 0) {
|
|
930
|
+
writeUpdatedSkillFiles(projectRoot, target, provenance.source, normalizedRemoteFiles, remoteReports, warnings, scriptTrust);
|
|
931
|
+
writeTrustedScriptCommandContract(projectRoot, target, provenance.source, scriptTrust);
|
|
932
|
+
}
|
|
933
|
+
return {
|
|
934
|
+
skill_name: skillName,
|
|
935
|
+
ok: true,
|
|
936
|
+
status: options.action === 'update' && mode === 'install' && changedFiles.length > 0
|
|
937
|
+
? 'updated'
|
|
938
|
+
: changedFiles.length > 0
|
|
939
|
+
? 'outdated'
|
|
940
|
+
: 'current',
|
|
941
|
+
source: provenance.source,
|
|
942
|
+
target,
|
|
943
|
+
current_files: installedFiles,
|
|
944
|
+
remote_files: remoteReports,
|
|
945
|
+
changed_files: changedFiles,
|
|
946
|
+
script_trust: scriptTrust,
|
|
947
|
+
warnings,
|
|
948
|
+
issues: [],
|
|
949
|
+
wrote_files: options.action === 'update' && mode === 'install' && changedFiles.length > 0,
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
catch (error) {
|
|
953
|
+
return rejectedUpdateItem(skillName, error instanceof Error ? error.message : String(error));
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
function selectedExternalSkillNames(projectRoot, options) {
|
|
957
|
+
if (options.all === true || (options.action === 'outdated' && (!options.skillNames || options.skillNames.length === 0))) {
|
|
958
|
+
return installedExternalSkillNames(projectRoot);
|
|
959
|
+
}
|
|
960
|
+
return [...new Set(options.skillNames ?? [])].sort((left, right) => left.localeCompare(right));
|
|
961
|
+
}
|
|
962
|
+
export async function createExternalSkillUpdateReport(projectRoot, options) {
|
|
963
|
+
const action = options.action;
|
|
964
|
+
const mode = action === 'outdated' ? 'check' : options.mode ?? 'install';
|
|
965
|
+
const checkState = createExternalSkillUpdateCheckState(projectRoot, options.now ?? new Date());
|
|
966
|
+
const warnings = [];
|
|
967
|
+
const issues = [];
|
|
968
|
+
if (action === 'update' && options.all !== true && (!options.skillNames || options.skillNames.length === 0)) {
|
|
969
|
+
issues.push('mf skill update requires a skill name or --all.');
|
|
970
|
+
}
|
|
971
|
+
if (options.all === true && options.skillNames && options.skillNames.length > 0) {
|
|
972
|
+
issues.push(`mf skill ${action} accepts either --all or skill names, not both.`);
|
|
973
|
+
}
|
|
974
|
+
if (action === 'update' && options.skillNames && options.skillNames.length > 1 && options.all !== true) {
|
|
975
|
+
issues.push('mf skill update accepts one skill name at a time unless --all is used.');
|
|
976
|
+
}
|
|
977
|
+
const requestedNames = selectedExternalSkillNames(projectRoot, options);
|
|
978
|
+
for (const skillName of requestedNames) {
|
|
979
|
+
if (!SLUG_PATTERN.test(skillName)) {
|
|
980
|
+
issues.push(`Invalid external skill name: ${skillName}`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
if (issues.length > 0) {
|
|
984
|
+
return {
|
|
985
|
+
schema_version: '1',
|
|
986
|
+
kind: 'skill_update_report',
|
|
987
|
+
command: 'skill',
|
|
988
|
+
action,
|
|
989
|
+
ok: false,
|
|
990
|
+
mode,
|
|
991
|
+
status: 'rejected',
|
|
992
|
+
check_state: checkState,
|
|
993
|
+
skills: [],
|
|
994
|
+
warnings,
|
|
995
|
+
issues,
|
|
996
|
+
wrote_files: false,
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
const installedNames = new Set(installedExternalSkillNames(projectRoot));
|
|
1000
|
+
const missingNames = requestedNames.filter((name) => !installedNames.has(name));
|
|
1001
|
+
const existingNames = requestedNames.filter((name) => installedNames.has(name));
|
|
1002
|
+
const skills = [
|
|
1003
|
+
...missingNames.map((name) => rejectedUpdateItem(name, `External skill is not installed: ${name}`)),
|
|
1004
|
+
];
|
|
1005
|
+
for (const skillName of existingNames) {
|
|
1006
|
+
skills.push(await createExternalSkillUpdateItem(projectRoot, skillName, {
|
|
1007
|
+
action,
|
|
1008
|
+
mode,
|
|
1009
|
+
trustScripts: options.trustScripts,
|
|
1010
|
+
fetch: options.fetch,
|
|
1011
|
+
}));
|
|
1012
|
+
}
|
|
1013
|
+
if (installedNames.size > 0 || requestedNames.length > 0) {
|
|
1014
|
+
writeExternalSkillUpdateCheckState(projectRoot, checkState.checked_at);
|
|
1015
|
+
}
|
|
1016
|
+
const wroteFiles = skills.some((skill) => skill.wrote_files);
|
|
1017
|
+
const ok = skills.every((skill) => skill.ok);
|
|
1018
|
+
return {
|
|
1019
|
+
schema_version: '1',
|
|
1020
|
+
kind: 'skill_update_report',
|
|
1021
|
+
command: 'skill',
|
|
1022
|
+
action,
|
|
1023
|
+
ok,
|
|
1024
|
+
mode,
|
|
1025
|
+
status: ok
|
|
1026
|
+
? wroteFiles
|
|
1027
|
+
? 'updated'
|
|
1028
|
+
: mode === 'dry_run'
|
|
1029
|
+
? 'preview'
|
|
1030
|
+
: 'checked'
|
|
1031
|
+
: 'rejected',
|
|
1032
|
+
check_state: checkState,
|
|
1033
|
+
skills,
|
|
1034
|
+
warnings,
|
|
1035
|
+
issues: skills.flatMap((skill) => skill.issues),
|
|
1036
|
+
wrote_files: wroteFiles,
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
413
1039
|
export async function createExternalSkillImportReport(projectRoot, inputUrl, options) {
|
|
414
1040
|
const mode = options.mode;
|
|
415
1041
|
try {
|
|
@@ -424,14 +1050,31 @@ export async function createExternalSkillImportReport(projectRoot, inputUrl, opt
|
|
|
424
1050
|
const source = externalSkillSourceFromParsed(inputUrl, parsed);
|
|
425
1051
|
const files = normalizeImportedSkillFiles(sourceFiles);
|
|
426
1052
|
const reports = fileReports(files);
|
|
1053
|
+
const scriptTrust = createScriptTrust(projectRoot, target, reports, options.trustScripts === true, mode);
|
|
427
1054
|
const warnings = [
|
|
428
|
-
...(reports.some((file) => file.kind === 'script')
|
|
1055
|
+
...(reports.some((file) => file.kind === 'script') && scriptTrust.status !== 'trusted'
|
|
429
1056
|
? ['Imported scripts are inert reference files; mustflow does not grant command authority for external scripts.']
|
|
430
1057
|
: []),
|
|
1058
|
+
...(scriptTrust.status === 'trusted'
|
|
1059
|
+
? ['Imported scripts were trusted by request; mustflow created command-contract intents gated by network and destructive approvals.']
|
|
1060
|
+
: []),
|
|
1061
|
+
...(scriptTrust.status === 'planned'
|
|
1062
|
+
? ['Imported scripts would be trusted by request during install; dry-run only reports the command-contract plan.']
|
|
1063
|
+
: []),
|
|
431
1064
|
'External skills are untrusted until the agent reads and evaluates the selected SKILL.md.',
|
|
432
1065
|
];
|
|
433
1066
|
if (mode === 'install') {
|
|
434
|
-
|
|
1067
|
+
try {
|
|
1068
|
+
writeImportedSkillFiles(projectRoot, target, source, files, reports, warnings, scriptTrust);
|
|
1069
|
+
writeTrustedScriptCommandContract(projectRoot, target, source, scriptTrust);
|
|
1070
|
+
}
|
|
1071
|
+
catch (error) {
|
|
1072
|
+
rmSync(path.join(projectRoot, ...target.skill_dir.split('/')), { recursive: true, force: true });
|
|
1073
|
+
if (scriptTrust.fragment_path) {
|
|
1074
|
+
rmSync(path.join(projectRoot, ...scriptTrust.fragment_path.split('/')), { force: true });
|
|
1075
|
+
}
|
|
1076
|
+
throw error;
|
|
1077
|
+
}
|
|
435
1078
|
}
|
|
436
1079
|
return {
|
|
437
1080
|
schema_version: '1',
|
|
@@ -444,6 +1087,7 @@ export async function createExternalSkillImportReport(projectRoot, inputUrl, opt
|
|
|
444
1087
|
source,
|
|
445
1088
|
target,
|
|
446
1089
|
files: reports,
|
|
1090
|
+
script_trust: scriptTrust,
|
|
447
1091
|
warnings,
|
|
448
1092
|
issues: [],
|
|
449
1093
|
wrote_files: mode === 'install',
|