@rhize/skill-forge 0.6.1 → 0.7.1
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/LICENSE +1 -0
- package/README.md +78 -5
- package/dist/cli.js +629 -214
- package/dist/cli.js.map +1 -1
- package/dist/ingest-prompt.md +14 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -5,8 +5,7 @@ import { Command } from "commander";
|
|
|
5
5
|
import { createRequire } from "module";
|
|
6
6
|
|
|
7
7
|
// src/commands/add.ts
|
|
8
|
-
import {
|
|
9
|
-
import { join as join15 } from "path";
|
|
8
|
+
import { join as join16 } from "path";
|
|
10
9
|
|
|
11
10
|
// src/config.ts
|
|
12
11
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
@@ -296,16 +295,132 @@ function moveDir(src, dest) {
|
|
|
296
295
|
}
|
|
297
296
|
|
|
298
297
|
// src/install/promoteMcp.ts
|
|
299
|
-
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as
|
|
298
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
300
299
|
import { dirname } from "path";
|
|
300
|
+
|
|
301
|
+
// src/gate/scanShared.ts
|
|
302
|
+
import { readFileSync as readFileSync2, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
|
|
303
|
+
import { extname, join as join5 } from "path";
|
|
304
|
+
var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
|
|
305
|
+
var BINARY_EXT = /* @__PURE__ */ new Set([
|
|
306
|
+
".png",
|
|
307
|
+
".jpg",
|
|
308
|
+
".jpeg",
|
|
309
|
+
".gif",
|
|
310
|
+
".webp",
|
|
311
|
+
".ico",
|
|
312
|
+
".pdf",
|
|
313
|
+
".zip",
|
|
314
|
+
".tar",
|
|
315
|
+
".gz",
|
|
316
|
+
".woff",
|
|
317
|
+
".woff2",
|
|
318
|
+
".ttf",
|
|
319
|
+
".eot",
|
|
320
|
+
".mp4",
|
|
321
|
+
".mp3",
|
|
322
|
+
".wasm",
|
|
323
|
+
".bin",
|
|
324
|
+
".exe"
|
|
325
|
+
]);
|
|
326
|
+
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
327
|
+
function* walk(dir) {
|
|
328
|
+
let entries;
|
|
329
|
+
try {
|
|
330
|
+
entries = readdirSync2(dir, { withFileTypes: true });
|
|
331
|
+
} catch {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
for (const entry of entries) {
|
|
335
|
+
const full = join5(dir, entry.name);
|
|
336
|
+
if (entry.isDirectory()) {
|
|
337
|
+
if (SKIP_DIRS2.has(entry.name)) continue;
|
|
338
|
+
yield* walk(full);
|
|
339
|
+
} else if (entry.isFile()) {
|
|
340
|
+
yield full;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function readTextFile(file) {
|
|
345
|
+
if (BINARY_EXT.has(extname(file).toLowerCase())) return void 0;
|
|
346
|
+
try {
|
|
347
|
+
if (statSync2(file).size > MAX_FILE_BYTES) return void 0;
|
|
348
|
+
const content = readFileSync2(file, "utf-8");
|
|
349
|
+
if (content.slice(0, 512).includes("\0")) return void 0;
|
|
350
|
+
return content;
|
|
351
|
+
} catch {
|
|
352
|
+
return void 0;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
var BLOCK_SEVERITIES_BY_STRICTNESS = {
|
|
356
|
+
"block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
|
|
357
|
+
"block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
|
|
358
|
+
"warn-only": /* @__PURE__ */ new Set()
|
|
359
|
+
};
|
|
360
|
+
function verdictFor(findings, strictness) {
|
|
361
|
+
const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS[strictness];
|
|
362
|
+
if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
|
|
363
|
+
if (findings.length > 0) return "warn";
|
|
364
|
+
return "pass";
|
|
365
|
+
}
|
|
366
|
+
function formatSafetyFinding(f) {
|
|
367
|
+
return `${f.severity} [${f.rule}] ${f.file}: ${f.detail}`;
|
|
368
|
+
}
|
|
369
|
+
function isPinnedPackageSpec(spec) {
|
|
370
|
+
const withoutScope = spec.startsWith("@") ? spec.slice(spec.indexOf("/") + 1) : spec;
|
|
371
|
+
const atIdx = withoutScope.lastIndexOf("@");
|
|
372
|
+
if (atIdx <= 0) return false;
|
|
373
|
+
return /^\d/.test(withoutScope.slice(atIdx + 1));
|
|
374
|
+
}
|
|
375
|
+
function isNpxCommand(command) {
|
|
376
|
+
const base = command.split(/[\\/]/).pop() ?? command;
|
|
377
|
+
return base.replace(/\.(cmd|exe)$/i, "").toLowerCase() === "npx";
|
|
378
|
+
}
|
|
379
|
+
function packageSpecBase(spec) {
|
|
380
|
+
const scopeEnd = spec.startsWith("@") ? spec.indexOf("/") + 1 : 0;
|
|
381
|
+
const atIdx = spec.lastIndexOf("@");
|
|
382
|
+
return atIdx > scopeEnd ? spec.slice(0, atIdx) : spec;
|
|
383
|
+
}
|
|
384
|
+
var NPX_VALUE_FLAGS = /* @__PURE__ */ new Set(["p", "package", "c", "call", "cache", "userconfig", "shell", "n", "node-arg", "npm", "registry"]);
|
|
385
|
+
var NPX_PACKAGE_SPEC_FLAGS = /* @__PURE__ */ new Set(["p", "package"]);
|
|
386
|
+
function collectNpxPackageSpecs(args) {
|
|
387
|
+
const packageFlagSpecs = [];
|
|
388
|
+
let positionalSpec;
|
|
389
|
+
for (let i = 0; i < args.length; i++) {
|
|
390
|
+
const arg = args[i];
|
|
391
|
+
if (arg === "--") break;
|
|
392
|
+
if (arg.startsWith("-")) {
|
|
393
|
+
const eqIdx = arg.indexOf("=");
|
|
394
|
+
const key = (eqIdx === -1 ? arg : arg.slice(0, eqIdx)).replace(/^-+/, "");
|
|
395
|
+
const attached = eqIdx === -1 ? void 0 : arg.slice(eqIdx + 1);
|
|
396
|
+
if (NPX_VALUE_FLAGS.has(key)) {
|
|
397
|
+
let value = attached;
|
|
398
|
+
if (value === void 0) {
|
|
399
|
+
value = args[i + 1];
|
|
400
|
+
i++;
|
|
401
|
+
}
|
|
402
|
+
if (NPX_PACKAGE_SPEC_FLAGS.has(key) && value !== void 0) {
|
|
403
|
+
packageFlagSpecs.push(value);
|
|
404
|
+
}
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
positionalSpec = arg;
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
return { packageFlagSpecs, positionalSpec };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// src/install/promoteMcp.ts
|
|
301
416
|
function promoteMcp(_entry, profile, targetConfigPath, options = {}) {
|
|
302
417
|
const force = options.force ?? false;
|
|
303
418
|
const serverName = profile.name;
|
|
304
|
-
const { command, args, envVarsToFill } = deriveServerEntry(profile);
|
|
419
|
+
const { command, args, envVarsToFill, autoPinned } = deriveServerEntry(profile);
|
|
305
420
|
let config = {};
|
|
306
421
|
let rawExisting;
|
|
307
422
|
if (existsSync5(targetConfigPath)) {
|
|
308
|
-
rawExisting =
|
|
423
|
+
rawExisting = readFileSync3(targetConfigPath, "utf-8");
|
|
309
424
|
try {
|
|
310
425
|
config = JSON.parse(rawExisting);
|
|
311
426
|
} catch (err) {
|
|
@@ -330,7 +445,7 @@ function promoteMcp(_entry, profile, targetConfigPath, options = {}) {
|
|
|
330
445
|
mkdirSync4(dirname(targetConfigPath), { recursive: true });
|
|
331
446
|
writeFileSync2(targetConfigPath, `${JSON.stringify(config, null, 2)}
|
|
332
447
|
`);
|
|
333
|
-
return { target: targetConfigPath, serverName, envVarsToFill, backupPath };
|
|
448
|
+
return { target: targetConfigPath, serverName, envVarsToFill, backupPath, autoPinned };
|
|
334
449
|
}
|
|
335
450
|
function deriveServerEntry(profile) {
|
|
336
451
|
const envNames = new Set(profile.envVars ?? []);
|
|
@@ -338,9 +453,41 @@ function deriveServerEntry(profile) {
|
|
|
338
453
|
for (const key of Object.keys(profile.candidateEntry.env)) envNames.add(key);
|
|
339
454
|
}
|
|
340
455
|
if (profile.candidateEntry) {
|
|
456
|
+
const { command, args = [] } = profile.candidateEntry;
|
|
457
|
+
if (isNpxCommand(command)) {
|
|
458
|
+
const { packageFlagSpecs, positionalSpec } = collectNpxPackageSpecs(args);
|
|
459
|
+
const allSpecs = positionalSpec ? [...packageFlagSpecs, positionalSpec] : packageFlagSpecs;
|
|
460
|
+
const unpinned = allSpecs.filter((spec) => !isPinnedPackageSpec(spec));
|
|
461
|
+
if (unpinned.length > 0) {
|
|
462
|
+
const isSoleUnpinnedPositional = packageFlagSpecs.length === 0 && allSpecs.length === 1 && positionalSpec === unpinned[0];
|
|
463
|
+
if (isSoleUnpinnedPositional) {
|
|
464
|
+
const spec = unpinned[0];
|
|
465
|
+
const specPackage = packageSpecBase(spec);
|
|
466
|
+
if (profile.packageName && specPackage === profile.packageName && profile.version) {
|
|
467
|
+
const to = `${profile.packageName}@${profile.version}`;
|
|
468
|
+
const specIdx = args.indexOf(spec);
|
|
469
|
+
const pinnedArgs = [...args];
|
|
470
|
+
pinnedArgs[specIdx] = to;
|
|
471
|
+
return {
|
|
472
|
+
command,
|
|
473
|
+
args: pinnedArgs,
|
|
474
|
+
envVarsToFill: Array.from(envNames),
|
|
475
|
+
autoPinned: { from: spec, to }
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
const reason = profile.packageName && specPackage !== profile.packageName ? `the launch spec names package "${specPackage}", but the candidate's own package.json is "${profile.packageName}" \u2014 pinning it to a version scanned for a different package would be unsafe` : "no version was found in the candidate's own package.json to pin it to";
|
|
479
|
+
throw new UserError(
|
|
480
|
+
`cannot promote MCP server "${profile.name}": its documented launch command "npx ${spec}" is unpinned and can't be safely auto-pinned (${reason}). Pin the spec to an exact version in the candidate's config (e.g. \`${specPackage}@<version>\`) and try again \u2014 there is no override for this.`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
throw new UserError(
|
|
484
|
+
`cannot promote MCP server "${profile.name}": its documented launch command "npx" has unpinned package spec(s) \u2014 ${unpinned.map((s) => `"${s}"`).join(", ")} \u2014 and this shape (a -p/--package dependency, or more than one spec) can't be safely auto-pinned. Pin every spec to an exact version in the candidate's config and try again \u2014 there is no override for this.`
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
341
488
|
return {
|
|
342
|
-
command
|
|
343
|
-
args
|
|
489
|
+
command,
|
|
490
|
+
args,
|
|
344
491
|
envVarsToFill: Array.from(envNames)
|
|
345
492
|
};
|
|
346
493
|
}
|
|
@@ -357,18 +504,22 @@ function deriveServerEntry(profile) {
|
|
|
357
504
|
}
|
|
358
505
|
|
|
359
506
|
// src/install/mcpCandidate.ts
|
|
360
|
-
import { readFileSync as
|
|
361
|
-
|
|
507
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
508
|
+
import { dirname as dirname2, join as join6 } from "path";
|
|
509
|
+
function readPackageInfo(pkgPath) {
|
|
362
510
|
try {
|
|
363
|
-
const pkg2 = JSON.parse(
|
|
364
|
-
return
|
|
511
|
+
const pkg2 = JSON.parse(readFileSync4(pkgPath, "utf-8"));
|
|
512
|
+
return {
|
|
513
|
+
name: typeof pkg2.name === "string" ? pkg2.name : void 0,
|
|
514
|
+
version: typeof pkg2.version === "string" ? pkg2.version : void 0
|
|
515
|
+
};
|
|
365
516
|
} catch {
|
|
366
|
-
return
|
|
517
|
+
return {};
|
|
367
518
|
}
|
|
368
519
|
}
|
|
369
520
|
function readConfigEntry(configPath, name) {
|
|
370
521
|
try {
|
|
371
|
-
const cfg = JSON.parse(
|
|
522
|
+
const cfg = JSON.parse(readFileSync4(configPath, "utf-8"));
|
|
372
523
|
const servers = cfg.mcpServers ?? cfg.servers ?? {};
|
|
373
524
|
const raw = servers[name] ?? {};
|
|
374
525
|
return {
|
|
@@ -383,15 +534,23 @@ function readConfigEntry(configPath, name) {
|
|
|
383
534
|
function resolveMcpCandidateDetail(profile) {
|
|
384
535
|
const name = profile.servers[0];
|
|
385
536
|
if (profile.config.endsWith("package.json")) {
|
|
386
|
-
return { name, version:
|
|
537
|
+
return { name, version: readPackageInfo(profile.config).version };
|
|
387
538
|
}
|
|
388
539
|
const entry = readConfigEntry(profile.config, name);
|
|
389
|
-
|
|
540
|
+
const sidecar = readPackageInfo(join6(dirname2(profile.config), "package.json"));
|
|
541
|
+
return {
|
|
542
|
+
name,
|
|
543
|
+
command: entry?.command,
|
|
544
|
+
args: entry?.args,
|
|
545
|
+
env: entry?.env,
|
|
546
|
+
version: sidecar.version,
|
|
547
|
+
packageName: sidecar.name
|
|
548
|
+
};
|
|
390
549
|
}
|
|
391
550
|
|
|
392
551
|
// src/provenance.ts
|
|
393
|
-
import { appendFileSync, existsSync as existsSync6, statSync as
|
|
394
|
-
import { join as
|
|
552
|
+
import { appendFileSync, existsSync as existsSync6, statSync as statSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
553
|
+
import { join as join7 } from "path";
|
|
395
554
|
var FORGE_VERBS = ["DEFER", "ABSORB", "FORK", "REJECT", "WATCH"];
|
|
396
555
|
var LEDGER_HEADER = "# Skill Forge \u2014 Provenance Ledger\n\nOne entry per external-skill ingestion decision.\n";
|
|
397
556
|
function todayLocalIsoDate() {
|
|
@@ -405,7 +564,7 @@ function appendProvenance(entry, skillsRoot) {
|
|
|
405
564
|
if (!FORGE_VERBS.includes(entry.verb)) {
|
|
406
565
|
throw new Error(`verb must be one of ${FORGE_VERBS.join(", ")}`);
|
|
407
566
|
}
|
|
408
|
-
if (!existsSync6(skillsRoot) || !
|
|
567
|
+
if (!existsSync6(skillsRoot) || !statSync3(skillsRoot).isDirectory()) {
|
|
409
568
|
throw new Error(`skills-root not a directory: ${skillsRoot}`);
|
|
410
569
|
}
|
|
411
570
|
const today = todayLocalIsoDate();
|
|
@@ -422,7 +581,7 @@ function appendProvenance(entry, skillsRoot) {
|
|
|
422
581
|
- **Drift check:** \`${driftCheck}\`
|
|
423
582
|
- **Notes:** ${entry.notes ?? ""}
|
|
424
583
|
`;
|
|
425
|
-
const ledgerPath =
|
|
584
|
+
const ledgerPath = join7(skillsRoot, "SOURCES.md");
|
|
426
585
|
if (!existsSync6(ledgerPath)) {
|
|
427
586
|
writeFileSync3(ledgerPath, LEDGER_HEADER, "utf-8");
|
|
428
587
|
}
|
|
@@ -432,7 +591,7 @@ function appendProvenance(entry, skillsRoot) {
|
|
|
432
591
|
|
|
433
592
|
// src/commands/gatePipeline.ts
|
|
434
593
|
import { readdirSync as readdirSync5 } from "fs";
|
|
435
|
-
import { join as
|
|
594
|
+
import { join as join12 } from "path";
|
|
436
595
|
|
|
437
596
|
// src/gate/profile.ts
|
|
438
597
|
import { execFileSync } from "child_process";
|
|
@@ -448,79 +607,11 @@ import {
|
|
|
448
607
|
statSync as statSync4
|
|
449
608
|
} from "fs";
|
|
450
609
|
import { tmpdir } from "os";
|
|
451
|
-
import { basename as basename3, dirname as
|
|
610
|
+
import { basename as basename3, dirname as dirname3, extname as extname3, join as join9, resolve as resolve2 } from "path";
|
|
452
611
|
|
|
453
612
|
// src/gate/mcpCapabilities.ts
|
|
454
613
|
import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
|
|
455
|
-
import { extname as extname2, join as
|
|
456
|
-
|
|
457
|
-
// src/gate/scanShared.ts
|
|
458
|
-
import { readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
|
|
459
|
-
import { extname, join as join6 } from "path";
|
|
460
|
-
var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
|
|
461
|
-
var BINARY_EXT = /* @__PURE__ */ new Set([
|
|
462
|
-
".png",
|
|
463
|
-
".jpg",
|
|
464
|
-
".jpeg",
|
|
465
|
-
".gif",
|
|
466
|
-
".webp",
|
|
467
|
-
".ico",
|
|
468
|
-
".pdf",
|
|
469
|
-
".zip",
|
|
470
|
-
".tar",
|
|
471
|
-
".gz",
|
|
472
|
-
".woff",
|
|
473
|
-
".woff2",
|
|
474
|
-
".ttf",
|
|
475
|
-
".eot",
|
|
476
|
-
".mp4",
|
|
477
|
-
".mp3",
|
|
478
|
-
".wasm",
|
|
479
|
-
".bin",
|
|
480
|
-
".exe"
|
|
481
|
-
]);
|
|
482
|
-
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
483
|
-
function* walk(dir) {
|
|
484
|
-
let entries;
|
|
485
|
-
try {
|
|
486
|
-
entries = readdirSync2(dir, { withFileTypes: true });
|
|
487
|
-
} catch {
|
|
488
|
-
return;
|
|
489
|
-
}
|
|
490
|
-
for (const entry of entries) {
|
|
491
|
-
const full = join6(dir, entry.name);
|
|
492
|
-
if (entry.isDirectory()) {
|
|
493
|
-
if (SKIP_DIRS2.has(entry.name)) continue;
|
|
494
|
-
yield* walk(full);
|
|
495
|
-
} else if (entry.isFile()) {
|
|
496
|
-
yield full;
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
function readTextFile(file) {
|
|
501
|
-
if (BINARY_EXT.has(extname(file).toLowerCase())) return void 0;
|
|
502
|
-
try {
|
|
503
|
-
if (statSync3(file).size > MAX_FILE_BYTES) return void 0;
|
|
504
|
-
const content = readFileSync4(file, "utf-8");
|
|
505
|
-
if (content.slice(0, 512).includes("\0")) return void 0;
|
|
506
|
-
return content;
|
|
507
|
-
} catch {
|
|
508
|
-
return void 0;
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
var BLOCK_SEVERITIES_BY_STRICTNESS = {
|
|
512
|
-
"block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
|
|
513
|
-
"block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
|
|
514
|
-
"warn-only": /* @__PURE__ */ new Set()
|
|
515
|
-
};
|
|
516
|
-
function verdictFor(findings, strictness) {
|
|
517
|
-
const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS[strictness];
|
|
518
|
-
if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
|
|
519
|
-
if (findings.length > 0) return "warn";
|
|
520
|
-
return "pass";
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
// src/gate/mcpCapabilities.ts
|
|
614
|
+
import { extname as extname2, join as join8 } from "path";
|
|
524
615
|
var SOURCE_EXT = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
525
616
|
var MAX_ITEMS_PER_CATEGORY = 200;
|
|
526
617
|
function readJsonSafe(file) {
|
|
@@ -626,7 +717,7 @@ var MCP_SDK_IMPORT_RE = /@modelcontextprotocol\/sdk/;
|
|
|
626
717
|
function extractMcpCapabilities(packageDir) {
|
|
627
718
|
const buckets = { tools: /* @__PURE__ */ new Set(), resources: /* @__PURE__ */ new Set(), prompts: /* @__PURE__ */ new Set() };
|
|
628
719
|
let hasPackageJsonMarker = false;
|
|
629
|
-
const pkgPath =
|
|
720
|
+
const pkgPath = join8(packageDir, "package.json");
|
|
630
721
|
if (existsSync7(pkgPath)) {
|
|
631
722
|
const pkg2 = readJsonSafe(pkgPath);
|
|
632
723
|
if (pkg2 && typeof pkg2 === "object" && !Array.isArray(pkg2)) {
|
|
@@ -634,7 +725,7 @@ function extractMcpCapabilities(packageDir) {
|
|
|
634
725
|
}
|
|
635
726
|
}
|
|
636
727
|
for (const manifestName of [".mcp.json", "mcp.json"]) {
|
|
637
|
-
const manifestPath =
|
|
728
|
+
const manifestPath = join8(packageDir, manifestName);
|
|
638
729
|
if (!existsSync7(manifestPath)) continue;
|
|
639
730
|
const parsed = readJsonSafe(manifestPath);
|
|
640
731
|
if (parsed !== void 0) {
|
|
@@ -725,8 +816,24 @@ function profileSkill(inputPath) {
|
|
|
725
816
|
}
|
|
726
817
|
return profileSkillDir(p);
|
|
727
818
|
}
|
|
819
|
+
function asSkillProfile(profile, context) {
|
|
820
|
+
if (profile.kind !== "skill") {
|
|
821
|
+
throw new Error(`expected a skill candidate ${context}, found a ${profile.kind}`);
|
|
822
|
+
}
|
|
823
|
+
return profile;
|
|
824
|
+
}
|
|
825
|
+
function asProfile(candidatePath, artifact) {
|
|
826
|
+
const profile = profileSkill(candidatePath);
|
|
827
|
+
if (artifact === "mcp") {
|
|
828
|
+
if (profile.kind !== "mcp") {
|
|
829
|
+
throw new Error(`expected an MCP candidate at ${candidatePath} (--artifact mcp), found a ${profile.kind}`);
|
|
830
|
+
}
|
|
831
|
+
return profile;
|
|
832
|
+
}
|
|
833
|
+
return asSkillProfile(profile, `at ${candidatePath}`);
|
|
834
|
+
}
|
|
728
835
|
function classifyInput(p) {
|
|
729
|
-
if (existsSync8(
|
|
836
|
+
if (existsSync8(join9(p, ".claude-plugin", "plugin.json"))) return "plugin";
|
|
730
837
|
if (detectArtifactType(p) === "mcp") return "mcp";
|
|
731
838
|
return "skill";
|
|
732
839
|
}
|
|
@@ -734,7 +841,7 @@ var MCP_SDK_DEP2 = "@modelcontextprotocol/sdk";
|
|
|
734
841
|
var MCP_KEYWORD2 = "mcp-server";
|
|
735
842
|
var MCP_BIN_PREFIX2 = /^mcp-/i;
|
|
736
843
|
function readPackageJsonSafe(root) {
|
|
737
|
-
const pkgPath =
|
|
844
|
+
const pkgPath = join9(root, "package.json");
|
|
738
845
|
if (!existsSync8(pkgPath)) return void 0;
|
|
739
846
|
try {
|
|
740
847
|
return JSON.parse(readFileSync6(pkgPath, "utf-8"));
|
|
@@ -758,7 +865,7 @@ function hasMcpPackageMarkers(pkg2) {
|
|
|
758
865
|
return false;
|
|
759
866
|
}
|
|
760
867
|
function detectArtifactType(root) {
|
|
761
|
-
if (existsSync8(
|
|
868
|
+
if (existsSync8(join9(root, ".mcp.json")) || existsSync8(join9(root, "mcp.json"))) return "mcp";
|
|
762
869
|
const pkg2 = readPackageJsonSafe(root);
|
|
763
870
|
if (pkg2 && hasMcpPackageMarkers(pkg2)) return "mcp";
|
|
764
871
|
return "skill";
|
|
@@ -768,22 +875,22 @@ function resolveSkillMd(inputPath) {
|
|
|
768
875
|
let tmpDir;
|
|
769
876
|
const initialStat = statSync4(currentPath);
|
|
770
877
|
if (extname3(currentPath) === ".skill" || initialStat.isFile() && isZipFile(currentPath)) {
|
|
771
|
-
tmpDir = mkdtempSync(
|
|
878
|
+
tmpDir = mkdtempSync(join9(tmpdir(), "forge_"));
|
|
772
879
|
extractZip(currentPath, tmpDir);
|
|
773
880
|
currentPath = tmpDir;
|
|
774
881
|
}
|
|
775
882
|
const stat = statSync4(currentPath);
|
|
776
883
|
if (stat.isFile() && basename3(currentPath) === "SKILL.md") {
|
|
777
|
-
return { skillMd: currentPath, root:
|
|
884
|
+
return { skillMd: currentPath, root: dirname3(currentPath), tmpDir };
|
|
778
885
|
}
|
|
779
886
|
if (stat.isDirectory()) {
|
|
780
|
-
const direct =
|
|
887
|
+
const direct = join9(currentPath, "SKILL.md");
|
|
781
888
|
if (existsSync8(direct)) {
|
|
782
889
|
return { skillMd: direct, root: currentPath, tmpDir };
|
|
783
890
|
}
|
|
784
891
|
const matches = findSkillMdOneLevelDown(currentPath);
|
|
785
892
|
if (matches.length === 1) {
|
|
786
|
-
return { skillMd: matches[0], root:
|
|
893
|
+
return { skillMd: matches[0], root: dirname3(matches[0]), tmpDir };
|
|
787
894
|
}
|
|
788
895
|
if (matches.length > 1) {
|
|
789
896
|
cleanupTmp(tmpDir);
|
|
@@ -803,15 +910,15 @@ function findSkillMdOneLevelDown(dir) {
|
|
|
803
910
|
const matches = /* @__PURE__ */ new Set();
|
|
804
911
|
for (const entry of readdirSync3(dir, { withFileTypes: true })) {
|
|
805
912
|
if (entry.isDirectory()) {
|
|
806
|
-
const candidate =
|
|
913
|
+
const candidate = join9(dir, entry.name, "SKILL.md");
|
|
807
914
|
if (existsSync8(candidate)) matches.add(candidate);
|
|
808
915
|
}
|
|
809
916
|
}
|
|
810
|
-
const skillsDir =
|
|
917
|
+
const skillsDir = join9(dir, "skills");
|
|
811
918
|
if (isDir(skillsDir)) {
|
|
812
919
|
for (const entry of readdirSync3(skillsDir, { withFileTypes: true })) {
|
|
813
920
|
if (entry.isDirectory()) {
|
|
814
|
-
const candidate =
|
|
921
|
+
const candidate = join9(skillsDir, entry.name, "SKILL.md");
|
|
815
922
|
if (existsSync8(candidate)) matches.add(candidate);
|
|
816
923
|
}
|
|
817
924
|
}
|
|
@@ -895,7 +1002,7 @@ function detectLicense(root, fm) {
|
|
|
895
1002
|
if (fm.license) return fm.license;
|
|
896
1003
|
const names = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "license"];
|
|
897
1004
|
for (const name of names) {
|
|
898
|
-
const p =
|
|
1005
|
+
const p = join9(root, name);
|
|
899
1006
|
if (existsSync8(p) && statSync4(p).isFile()) {
|
|
900
1007
|
const head = readFileSync6(p, "utf-8").slice(0, 2e3).toLowerCase();
|
|
901
1008
|
for (const [hint, label] of Object.entries(SPDX_HINTS)) {
|
|
@@ -909,7 +1016,7 @@ function detectLicense(root, fm) {
|
|
|
909
1016
|
function walkFiles(dir) {
|
|
910
1017
|
const out = [];
|
|
911
1018
|
for (const entry of readdirSync3(dir, { withFileTypes: true })) {
|
|
912
|
-
const full =
|
|
1019
|
+
const full = join9(dir, entry.name);
|
|
913
1020
|
if (entry.isDirectory()) {
|
|
914
1021
|
out.push(...walkFiles(full));
|
|
915
1022
|
} else if (entry.isFile()) {
|
|
@@ -921,7 +1028,7 @@ function walkFiles(dir) {
|
|
|
921
1028
|
function countResources(root) {
|
|
922
1029
|
const resources = {};
|
|
923
1030
|
for (const d of RESOURCE_DIRS) {
|
|
924
|
-
const dp =
|
|
1031
|
+
const dp = join9(root, d);
|
|
925
1032
|
if (isDir(dp)) {
|
|
926
1033
|
resources[d] = walkFiles(dp).length;
|
|
927
1034
|
}
|
|
@@ -932,7 +1039,7 @@ function scanMcpAndImports(text, root) {
|
|
|
932
1039
|
const mcpRefRe = /mcp__[a-zA-Z0-9_]+/g;
|
|
933
1040
|
const mcpRefs = new Set(text.match(mcpRefRe) ?? []);
|
|
934
1041
|
const imports = /* @__PURE__ */ new Set();
|
|
935
|
-
const scriptsDir =
|
|
1042
|
+
const scriptsDir = join9(root, "scripts");
|
|
936
1043
|
if (isDir(scriptsDir)) {
|
|
937
1044
|
for (const f of walkFiles(scriptsDir)) {
|
|
938
1045
|
if (!f.endsWith(".py")) continue;
|
|
@@ -981,19 +1088,19 @@ function profileSkillDir(inputPath) {
|
|
|
981
1088
|
}
|
|
982
1089
|
}
|
|
983
1090
|
function profilePlugin(p) {
|
|
984
|
-
const manifestPath =
|
|
1091
|
+
const manifestPath = join9(p, ".claude-plugin", "plugin.json");
|
|
985
1092
|
let manifest;
|
|
986
1093
|
try {
|
|
987
1094
|
manifest = JSON.parse(readFileSync6(manifestPath, "utf-8"));
|
|
988
1095
|
} catch (err) {
|
|
989
1096
|
throw new Error(`could not parse plugin.json under ${p}: ${err.message}`);
|
|
990
1097
|
}
|
|
991
|
-
const skillsDir =
|
|
1098
|
+
const skillsDir = join9(p, "skills");
|
|
992
1099
|
let skills = [];
|
|
993
1100
|
if (isDir(skillsDir)) {
|
|
994
|
-
skills = readdirSync3(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync8(
|
|
1101
|
+
skills = readdirSync3(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync8(join9(skillsDir, d.name, "SKILL.md"))).map((d) => d.name).sort();
|
|
995
1102
|
}
|
|
996
|
-
const commandsDir =
|
|
1103
|
+
const commandsDir = join9(p, "commands");
|
|
997
1104
|
const commands = isDir(commandsDir) ? readdirSync3(commandsDir).filter((f) => f.endsWith(".md")).length : 0;
|
|
998
1105
|
const mc = manifest.mcpServers ?? manifest.mcp_servers ?? {};
|
|
999
1106
|
const mcpServers = typeof mc === "object" && mc !== null ? Object.keys(mc).sort() : [];
|
|
@@ -1011,7 +1118,7 @@ function profilePlugin(p) {
|
|
|
1011
1118
|
};
|
|
1012
1119
|
}
|
|
1013
1120
|
function profileMcp(p) {
|
|
1014
|
-
const explicitConfig = existsSync8(
|
|
1121
|
+
const explicitConfig = existsSync8(join9(p, ".mcp.json")) ? join9(p, ".mcp.json") : existsSync8(join9(p, "mcp.json")) ? join9(p, "mcp.json") : void 0;
|
|
1015
1122
|
if (explicitConfig) {
|
|
1016
1123
|
let cfg;
|
|
1017
1124
|
try {
|
|
@@ -1031,7 +1138,7 @@ function profileMcp(p) {
|
|
|
1031
1138
|
capabilities: extractMcpCapabilities(p)
|
|
1032
1139
|
};
|
|
1033
1140
|
}
|
|
1034
|
-
const pkgPath =
|
|
1141
|
+
const pkgPath = join9(p, "package.json");
|
|
1035
1142
|
let pkg2;
|
|
1036
1143
|
try {
|
|
1037
1144
|
pkg2 = JSON.parse(readFileSync6(pkgPath, "utf-8"));
|
|
@@ -1053,7 +1160,7 @@ function profileMcp(p) {
|
|
|
1053
1160
|
// src/gate/safety.ts
|
|
1054
1161
|
import { statSync as statSync5 } from "fs";
|
|
1055
1162
|
import { execFile } from "child_process";
|
|
1056
|
-
import { delimiter, join as
|
|
1163
|
+
import { delimiter, join as join10 } from "path";
|
|
1057
1164
|
import { promisify } from "util";
|
|
1058
1165
|
var execFileAsync = promisify(execFile);
|
|
1059
1166
|
var DENY_RULES = [
|
|
@@ -1152,7 +1259,7 @@ function which(cmd) {
|
|
|
1152
1259
|
const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
1153
1260
|
for (const dir of dirs) {
|
|
1154
1261
|
for (const ext of exts) {
|
|
1155
|
-
const candidate =
|
|
1262
|
+
const candidate = join10(dir, cmd + ext);
|
|
1156
1263
|
try {
|
|
1157
1264
|
if (statSync5(candidate).isFile()) return candidate;
|
|
1158
1265
|
} catch {
|
|
@@ -1193,7 +1300,7 @@ async function scanSafety(targetPath, strictness = DEFAULT_STRICTNESS) {
|
|
|
1193
1300
|
|
|
1194
1301
|
// src/gate/overlap.ts
|
|
1195
1302
|
import { existsSync as existsSync9, readFileSync as readFileSync7, readdirSync as readdirSync4 } from "fs";
|
|
1196
|
-
import { basename as basename4, dirname as
|
|
1303
|
+
import { basename as basename4, dirname as dirname4, join as join11, resolve as resolve3 } from "path";
|
|
1197
1304
|
var STOPWORDS = new Set(
|
|
1198
1305
|
`
|
|
1199
1306
|
a an the and or of to for in on with use used using when whether this that these those is are be
|
|
@@ -1201,21 +1308,21 @@ your you it its as at by from into via per also any all not no than then so if u
|
|
|
1201
1308
|
skill skills user claude code based comprehensive enhanced practical including include includes
|
|
1202
1309
|
`.split(/\s+/).filter(Boolean)
|
|
1203
1310
|
);
|
|
1204
|
-
function splitLimited(text,
|
|
1311
|
+
function splitLimited(text, sep2, maxSplit) {
|
|
1205
1312
|
const parts = [];
|
|
1206
1313
|
let rest = text;
|
|
1207
1314
|
for (let i = 0; i < maxSplit; i++) {
|
|
1208
|
-
const idx = rest.indexOf(
|
|
1315
|
+
const idx = rest.indexOf(sep2);
|
|
1209
1316
|
if (idx === -1) break;
|
|
1210
1317
|
parts.push(rest.slice(0, idx));
|
|
1211
|
-
rest = rest.slice(idx +
|
|
1318
|
+
rest = rest.slice(idx + sep2.length);
|
|
1212
1319
|
}
|
|
1213
1320
|
parts.push(rest);
|
|
1214
1321
|
return parts;
|
|
1215
1322
|
}
|
|
1216
1323
|
function readFrontmatter(skillMdPath) {
|
|
1217
1324
|
const text = readFileSync7(skillMdPath, "utf-8");
|
|
1218
|
-
const meta = { name: basename4(
|
|
1325
|
+
const meta = { name: basename4(dirname4(skillMdPath)), description: "" };
|
|
1219
1326
|
if (text.startsWith("---\n")) {
|
|
1220
1327
|
const block = splitLimited(text, "---", 2)[1] ?? "";
|
|
1221
1328
|
const nameMatch = block.match(/^name:\s*(.+)$/m);
|
|
@@ -1316,21 +1423,21 @@ function listSubdirs(dir) {
|
|
|
1316
1423
|
} catch {
|
|
1317
1424
|
return [];
|
|
1318
1425
|
}
|
|
1319
|
-
return entries.filter((e) => e.isDirectory()).map((e) =>
|
|
1426
|
+
return entries.filter((e) => e.isDirectory()).map((e) => join11(dir, e.name));
|
|
1320
1427
|
}
|
|
1321
1428
|
function findSkillMdFiles(root) {
|
|
1322
1429
|
const found = /* @__PURE__ */ new Set();
|
|
1323
1430
|
for (const dir of listSubdirs(root)) {
|
|
1324
|
-
const candidate =
|
|
1431
|
+
const candidate = join11(dir, "SKILL.md");
|
|
1325
1432
|
if (existsSync9(candidate)) found.add(resolve3(candidate));
|
|
1326
1433
|
}
|
|
1327
|
-
for (const dir of listSubdirs(
|
|
1328
|
-
const candidate =
|
|
1434
|
+
for (const dir of listSubdirs(join11(root, "skills"))) {
|
|
1435
|
+
const candidate = join11(dir, "SKILL.md");
|
|
1329
1436
|
if (existsSync9(candidate)) found.add(resolve3(candidate));
|
|
1330
1437
|
}
|
|
1331
1438
|
for (const topDir of listSubdirs(root)) {
|
|
1332
|
-
for (const dir of listSubdirs(
|
|
1333
|
-
const candidate =
|
|
1439
|
+
for (const dir of listSubdirs(join11(topDir, "skills"))) {
|
|
1440
|
+
const candidate = join11(dir, "SKILL.md");
|
|
1334
1441
|
if (existsSync9(candidate)) found.add(resolve3(candidate));
|
|
1335
1442
|
}
|
|
1336
1443
|
}
|
|
@@ -1343,7 +1450,7 @@ function scanOverlap(candidatePath, skillsRoot) {
|
|
|
1343
1450
|
}
|
|
1344
1451
|
let cpath = resolve3(expandHome(candidatePath));
|
|
1345
1452
|
if (isDir(cpath)) {
|
|
1346
|
-
cpath =
|
|
1453
|
+
cpath = join11(cpath, "SKILL.md");
|
|
1347
1454
|
}
|
|
1348
1455
|
if (!existsSync9(cpath)) {
|
|
1349
1456
|
throw new Error(`candidate SKILL.md not found: ${cpath}`);
|
|
@@ -1398,21 +1505,6 @@ function looksHighEntropy(value) {
|
|
|
1398
1505
|
const uniqueChars = new Set(value).size;
|
|
1399
1506
|
return varietyCount >= 2 && uniqueChars / value.length > 0.4;
|
|
1400
1507
|
}
|
|
1401
|
-
function isPinnedPackageSpec(spec) {
|
|
1402
|
-
const withoutScope = spec.startsWith("@") ? spec.slice(spec.indexOf("/") + 1) : spec;
|
|
1403
|
-
const atIdx = withoutScope.lastIndexOf("@");
|
|
1404
|
-
if (atIdx <= 0) return false;
|
|
1405
|
-
return /^\d/.test(withoutScope.slice(atIdx + 1));
|
|
1406
|
-
}
|
|
1407
|
-
function isNpxCommand(command) {
|
|
1408
|
-
const base = command.split(/[\\/]/).pop() ?? command;
|
|
1409
|
-
return base.replace(/\.(cmd|exe)$/i, "").toLowerCase() === "npx";
|
|
1410
|
-
}
|
|
1411
|
-
function packageSpecBase(spec) {
|
|
1412
|
-
const scopeEnd = spec.startsWith("@") ? spec.indexOf("/") + 1 : 0;
|
|
1413
|
-
const atIdx = spec.lastIndexOf("@");
|
|
1414
|
-
return atIdx > scopeEnd ? spec.slice(0, atIdx) : spec;
|
|
1415
|
-
}
|
|
1416
1508
|
function collectServerNodes(node, out) {
|
|
1417
1509
|
if (Array.isArray(node)) {
|
|
1418
1510
|
for (const item of node) collectServerNodes(item, out);
|
|
@@ -1443,14 +1535,17 @@ function scanJsonConfig(file, parsed) {
|
|
|
1443
1535
|
if (command && isNpxCommand(command)) {
|
|
1444
1536
|
const flagIdx = args.findIndex((a) => a === "-y" || a === "--yes");
|
|
1445
1537
|
if (flagIdx !== -1) {
|
|
1446
|
-
const
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1538
|
+
const { packageFlagSpecs, positionalSpec } = collectNpxPackageSpecs(args);
|
|
1539
|
+
const allSpecs = positionalSpec ? [...packageFlagSpecs, positionalSpec] : packageFlagSpecs;
|
|
1540
|
+
for (const pkgSpec of allSpecs) {
|
|
1541
|
+
if (!isPinnedPackageSpec(pkgSpec)) {
|
|
1542
|
+
findings.push({
|
|
1543
|
+
severity: "MEDIUM",
|
|
1544
|
+
rule: "mcp-unpinned-npx",
|
|
1545
|
+
file,
|
|
1546
|
+
detail: `npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${packageSpecBase(pkgSpec)}@<version>) so the resolved server code can't change silently`
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1454
1549
|
}
|
|
1455
1550
|
}
|
|
1456
1551
|
}
|
|
@@ -1743,6 +1838,14 @@ function proFeatureStatus(feature, betaFree = BETA_FREE) {
|
|
|
1743
1838
|
}
|
|
1744
1839
|
|
|
1745
1840
|
// src/report/render.ts
|
|
1841
|
+
var SAFETY_VERDICT_MAP = {
|
|
1842
|
+
pass: "ALLOW",
|
|
1843
|
+
warn: "CAUTION",
|
|
1844
|
+
block: "BLOCK"
|
|
1845
|
+
};
|
|
1846
|
+
function mapSafetyFindings(findings) {
|
|
1847
|
+
return findings.map((f) => ({ severity: f.severity, rule: f.rule, message: `${f.file}: ${f.detail}` }));
|
|
1848
|
+
}
|
|
1746
1849
|
var BORDER = "\u2501".repeat(55);
|
|
1747
1850
|
var LABEL_WIDTH = 20;
|
|
1748
1851
|
function padLabel(text) {
|
|
@@ -1817,29 +1920,14 @@ function renderReport(result) {
|
|
|
1817
1920
|
function renderJson(result) {
|
|
1818
1921
|
return JSON.stringify(result, null, 2);
|
|
1819
1922
|
}
|
|
1820
|
-
|
|
1821
|
-
// src/commands/gatePipeline.ts
|
|
1822
|
-
var SAFETY_VERDICT_MAP = {
|
|
1823
|
-
pass: "ALLOW",
|
|
1824
|
-
warn: "CAUTION",
|
|
1825
|
-
block: "BLOCK"
|
|
1826
|
-
};
|
|
1827
1923
|
function printGateOutput(gateResult, json) {
|
|
1828
1924
|
if (!json) {
|
|
1829
1925
|
for (const notice of gateResult.notices ?? []) console.log(`[skill-forge] ${notice}`);
|
|
1830
1926
|
}
|
|
1831
1927
|
console.log(json ? renderJson(gateResult) : renderReport(gateResult));
|
|
1832
1928
|
}
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
const expectedKind = artifact === "mcp" ? "mcp" : "skill";
|
|
1836
|
-
if (profile.kind !== expectedKind) {
|
|
1837
|
-
throw new Error(
|
|
1838
|
-
artifact === "mcp" ? `expected an MCP candidate at ${candidatePath} (--artifact mcp), found a ${profile.kind}` : `expected a skill candidate at ${candidatePath}, found a ${profile.kind}`
|
|
1839
|
-
);
|
|
1840
|
-
}
|
|
1841
|
-
return profile;
|
|
1842
|
-
}
|
|
1929
|
+
|
|
1930
|
+
// src/commands/gatePipeline.ts
|
|
1843
1931
|
function tryOverlap(candidatePath, skillsRoot) {
|
|
1844
1932
|
try {
|
|
1845
1933
|
return scanOverlap(candidatePath, skillsRoot);
|
|
@@ -1872,7 +1960,7 @@ function resolveMcpCandidatePath(entry) {
|
|
|
1872
1960
|
children = readdirSync5(entry.quarantinePath, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
1873
1961
|
} catch {
|
|
1874
1962
|
}
|
|
1875
|
-
return children.length === 1 ?
|
|
1963
|
+
return children.length === 1 ? join12(entry.quarantinePath, children[0]) : entry.quarantinePath;
|
|
1876
1964
|
}
|
|
1877
1965
|
async function runGate(candidatePath, skillsRoot, {
|
|
1878
1966
|
proFeatureLabel = "overlap analysis",
|
|
@@ -1891,11 +1979,7 @@ async function runGate(candidatePath, skillsRoot, {
|
|
|
1891
1979
|
notices: proStatus.notice ? [proStatus.notice] : void 0,
|
|
1892
1980
|
safety: {
|
|
1893
1981
|
verdict: SAFETY_VERDICT_MAP[safety.verdict],
|
|
1894
|
-
findings: safety.findings
|
|
1895
|
-
severity: f.severity,
|
|
1896
|
-
rule: f.rule,
|
|
1897
|
-
message: `${f.file}: ${f.detail}`
|
|
1898
|
-
}))
|
|
1982
|
+
findings: mapSafetyFindings(safety.findings)
|
|
1899
1983
|
}
|
|
1900
1984
|
};
|
|
1901
1985
|
return { safety, gateResult, proEnabled: proStatus.enabled };
|
|
@@ -1904,9 +1988,9 @@ async function runGate(candidatePath, skillsRoot, {
|
|
|
1904
1988
|
// src/queue.ts
|
|
1905
1989
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
1906
1990
|
import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "fs";
|
|
1907
|
-
import { join as
|
|
1991
|
+
import { join as join13 } from "path";
|
|
1908
1992
|
function getQueuePath() {
|
|
1909
|
-
return
|
|
1993
|
+
return join13(getHomeDir(), "queue.json");
|
|
1910
1994
|
}
|
|
1911
1995
|
function readQueue() {
|
|
1912
1996
|
const path = getQueuePath();
|
|
@@ -1944,15 +2028,15 @@ function generateId(existingIds) {
|
|
|
1944
2028
|
// src/handoff.ts
|
|
1945
2029
|
import { spawnSync } from "child_process";
|
|
1946
2030
|
import { existsSync as existsSync12 } from "fs";
|
|
1947
|
-
import { dirname as
|
|
2031
|
+
import { dirname as dirname5, join as join14 } from "path";
|
|
1948
2032
|
import { fileURLToPath } from "url";
|
|
1949
2033
|
var KNOWN_AGENT_BINARIES = ["claude", "codex", "cursor-agent", "windsurf", "opencode", "gemini"];
|
|
1950
2034
|
var HANDOFF_INSTRUCTION = "Read {prompt} and follow its instructions for the skill installed at {path}";
|
|
1951
2035
|
function resolvePromptPath() {
|
|
1952
|
-
const here =
|
|
1953
|
-
const builtCandidate =
|
|
2036
|
+
const here = dirname5(fileURLToPath(import.meta.url));
|
|
2037
|
+
const builtCandidate = join14(here, "ingest-prompt.md");
|
|
1954
2038
|
if (existsSync12(builtCandidate)) return builtCandidate;
|
|
1955
|
-
return
|
|
2039
|
+
return join14(here, "..", "assets", "ingest-prompt.md");
|
|
1956
2040
|
}
|
|
1957
2041
|
function defaultIsOnPath(bin) {
|
|
1958
2042
|
const finder = process.platform === "win32" ? "where" : "which";
|
|
@@ -2014,7 +2098,7 @@ import { createInterface } from "readline/promises";
|
|
|
2014
2098
|
// src/agents.ts
|
|
2015
2099
|
import { statSync as statSync7 } from "fs";
|
|
2016
2100
|
import { homedir as homedir3 } from "os";
|
|
2017
|
-
import { join as
|
|
2101
|
+
import { join as join15, resolve as resolve5 } from "path";
|
|
2018
2102
|
function isFile(p) {
|
|
2019
2103
|
try {
|
|
2020
2104
|
return statSync7(p).isFile();
|
|
@@ -2028,7 +2112,7 @@ function projectPath(root, cwd) {
|
|
|
2028
2112
|
function globalPath(root, home) {
|
|
2029
2113
|
if (!root) return void 0;
|
|
2030
2114
|
if (root === "~") return home;
|
|
2031
|
-
if (root.startsWith("~/")) return
|
|
2115
|
+
if (root.startsWith("~/")) return join15(home, root.slice(2));
|
|
2032
2116
|
return root;
|
|
2033
2117
|
}
|
|
2034
2118
|
function makeAgent(spec) {
|
|
@@ -2429,13 +2513,38 @@ async function maybeOfferInit(options = {}) {
|
|
|
2429
2513
|
}
|
|
2430
2514
|
}
|
|
2431
2515
|
|
|
2432
|
-
// src/
|
|
2433
|
-
|
|
2434
|
-
var MCP_PRO_FEATURE_LABEL = "overlap analysis and the pending-ingestion queue";
|
|
2516
|
+
// src/decision.ts
|
|
2517
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
2435
2518
|
function logOutcome(message, json) {
|
|
2436
2519
|
if (json) console.error(message);
|
|
2437
2520
|
else console.log(message);
|
|
2438
2521
|
}
|
|
2522
|
+
function yesDecision(verdict) {
|
|
2523
|
+
return verdict === "block" ? "reject" : "promote";
|
|
2524
|
+
}
|
|
2525
|
+
async function promptDecision(verdict, subject) {
|
|
2526
|
+
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
2527
|
+
const defaultChoice = verdict === "block" ? "reject" : verdict === "warn" ? "hold" : "promote";
|
|
2528
|
+
const prompt = `Promote, hold, or reject ${subject}? [promote/hold/reject] (default: ${defaultChoice}) `;
|
|
2529
|
+
try {
|
|
2530
|
+
process.stdout.write(prompt);
|
|
2531
|
+
for await (const raw of rl) {
|
|
2532
|
+
const answer = raw.trim().toLowerCase() || defaultChoice;
|
|
2533
|
+
if (answer === "p" || answer === "promote") return "promote";
|
|
2534
|
+
if (answer === "h" || answer === "hold") return "hold";
|
|
2535
|
+
if (answer === "r" || answer === "reject") return "reject";
|
|
2536
|
+
process.stdout.write(`Please answer promote, hold, or reject (or p/h/r).
|
|
2537
|
+
${prompt}`);
|
|
2538
|
+
}
|
|
2539
|
+
return defaultChoice;
|
|
2540
|
+
} finally {
|
|
2541
|
+
rl.close();
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
// src/commands/add.ts
|
|
2546
|
+
var PRO_FEATURE_LABEL = "overlap analysis, the provenance ledger, and the pending-ingestion queue";
|
|
2547
|
+
var MCP_PRO_FEATURE_LABEL = "overlap analysis and the pending-ingestion queue";
|
|
2439
2548
|
async function add(source, options = {}) {
|
|
2440
2549
|
await maybeOfferInit({ json: options.json, yes: options.yes });
|
|
2441
2550
|
const config = loadConfig();
|
|
@@ -2472,7 +2581,7 @@ async function add(source, options = {}) {
|
|
|
2472
2581
|
throw new Error(`expected a skill candidate, found a ${profile.kind}`);
|
|
2473
2582
|
}
|
|
2474
2583
|
printGateOutput(gateResult, options.json);
|
|
2475
|
-
const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict);
|
|
2584
|
+
const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict, "this skill");
|
|
2476
2585
|
let installedPath = null;
|
|
2477
2586
|
if (decision === "promote") {
|
|
2478
2587
|
const result = promote(entry, targetRoot);
|
|
@@ -2512,7 +2621,7 @@ async function add(source, options = {}) {
|
|
|
2512
2621
|
gate: {
|
|
2513
2622
|
license: profile.license ?? null,
|
|
2514
2623
|
safetyVerdict: safety.verdict,
|
|
2515
|
-
safetyFindings: safety.findings.map(
|
|
2624
|
+
safetyFindings: safety.findings.map(formatSafetyFinding),
|
|
2516
2625
|
overlapTop: overlap ? overlap.ranking.slice(0, 3).map((r) => ({ skill: r.skill, score: r.score })) : [],
|
|
2517
2626
|
suggestedVerb: overlap ? suggestVerbCode(overlap.nearestScore) : null
|
|
2518
2627
|
},
|
|
@@ -2544,7 +2653,7 @@ async function addMcp(source, config, options) {
|
|
|
2544
2653
|
);
|
|
2545
2654
|
}
|
|
2546
2655
|
printGateOutput(gateResult, options.json);
|
|
2547
|
-
const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict);
|
|
2656
|
+
const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict, "this skill");
|
|
2548
2657
|
let installedPath = null;
|
|
2549
2658
|
if (decision === "promote") {
|
|
2550
2659
|
const detail = resolveMcpCandidateDetail(profile);
|
|
@@ -2553,13 +2662,20 @@ async function addMcp(source, config, options) {
|
|
|
2553
2662
|
// version may be absent — promoteMcp refuses the unpinnable npx fallback
|
|
2554
2663
|
// rather than writing "npx pkg@unknown"; see deriveServerEntry in promoteMcp.ts.
|
|
2555
2664
|
version: detail.version,
|
|
2665
|
+
// packageName (v0.7) — the candidate's own scanned package.json name, so
|
|
2666
|
+
// deriveServerEntry can auto-pin an unpinned npx spec in candidateEntry
|
|
2667
|
+
// only when it provably matches the package that was actually scanned.
|
|
2668
|
+
packageName: detail.packageName,
|
|
2556
2669
|
envVars: detail.env ? Object.keys(detail.env) : void 0,
|
|
2557
2670
|
candidateEntry: detail.command ? { command: detail.command, args: detail.args, env: detail.env } : void 0
|
|
2558
2671
|
};
|
|
2559
|
-
const mcpTargetPath = options.mcpTarget ?? config.mcpTargets?.[0] ??
|
|
2672
|
+
const mcpTargetPath = options.mcpTarget ?? config.mcpTargets?.[0] ?? join16(process.cwd(), ".mcp.json");
|
|
2560
2673
|
const result = promoteMcp(entry, serverProfile, mcpTargetPath, { force: options.force });
|
|
2561
2674
|
reject(entry);
|
|
2562
2675
|
logOutcome(`[skill-forge] promoted -> ${result.target} (server "${result.serverName}")`, options.json);
|
|
2676
|
+
if (result.autoPinned) {
|
|
2677
|
+
logOutcome(`[skill-forge] pinned launch spec ${result.autoPinned.from} -> ${result.autoPinned.to}`, options.json);
|
|
2678
|
+
}
|
|
2563
2679
|
if (result.backupPath) {
|
|
2564
2680
|
logOutcome(`[skill-forge] backed up existing config -> ${result.backupPath}`, options.json);
|
|
2565
2681
|
}
|
|
@@ -2585,7 +2701,7 @@ async function addMcp(source, config, options) {
|
|
|
2585
2701
|
gate: {
|
|
2586
2702
|
license: null,
|
|
2587
2703
|
safetyVerdict: safety.verdict,
|
|
2588
|
-
safetyFindings: safety.findings.map(
|
|
2704
|
+
safetyFindings: safety.findings.map(formatSafetyFinding),
|
|
2589
2705
|
overlapTop: overlap ? overlap.ranking.slice(0, 3).map((r) => ({ skill: r.skill, score: r.score })) : [],
|
|
2590
2706
|
suggestedVerb: overlap ? suggestVerbCode(overlap.nearestScore) : null
|
|
2591
2707
|
},
|
|
@@ -2598,28 +2714,6 @@ async function addMcp(source, config, options) {
|
|
|
2598
2714
|
}
|
|
2599
2715
|
}
|
|
2600
2716
|
}
|
|
2601
|
-
function yesDecision(verdict) {
|
|
2602
|
-
return verdict === "block" ? "reject" : "promote";
|
|
2603
|
-
}
|
|
2604
|
-
async function promptDecision(verdict) {
|
|
2605
|
-
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
2606
|
-
const defaultChoice = verdict === "block" ? "reject" : verdict === "warn" ? "hold" : "promote";
|
|
2607
|
-
const prompt = `Promote, hold, or reject this skill? [promote/hold/reject] (default: ${defaultChoice}) `;
|
|
2608
|
-
try {
|
|
2609
|
-
process.stdout.write(prompt);
|
|
2610
|
-
for await (const raw of rl) {
|
|
2611
|
-
const answer = raw.trim().toLowerCase() || defaultChoice;
|
|
2612
|
-
if (answer === "p" || answer === "promote") return "promote";
|
|
2613
|
-
if (answer === "h" || answer === "hold") return "hold";
|
|
2614
|
-
if (answer === "r" || answer === "reject") return "reject";
|
|
2615
|
-
process.stdout.write(`Please answer promote, hold, or reject (or p/h/r).
|
|
2616
|
-
${prompt}`);
|
|
2617
|
-
}
|
|
2618
|
-
return defaultChoice;
|
|
2619
|
-
} finally {
|
|
2620
|
-
rl.close();
|
|
2621
|
-
}
|
|
2622
|
-
}
|
|
2623
2717
|
|
|
2624
2718
|
// src/commands/scan.ts
|
|
2625
2719
|
async function scan(source, options = {}) {
|
|
@@ -2670,7 +2764,7 @@ async function scan(source, options = {}) {
|
|
|
2670
2764
|
}
|
|
2671
2765
|
|
|
2672
2766
|
// src/commands/list.ts
|
|
2673
|
-
import { join as
|
|
2767
|
+
import { join as join17 } from "path";
|
|
2674
2768
|
async function list() {
|
|
2675
2769
|
await maybeOfferInit();
|
|
2676
2770
|
const config = loadConfig();
|
|
@@ -2683,14 +2777,14 @@ async function list() {
|
|
|
2683
2777
|
}
|
|
2684
2778
|
console.log(`[skill-forge] ${ids.length} held in quarantine (${quarantineDir}):`);
|
|
2685
2779
|
for (const id of ids) {
|
|
2686
|
-
const skillDirs = findSkillDirs(
|
|
2780
|
+
const skillDirs = findSkillDirs(join17(quarantineDir, id));
|
|
2687
2781
|
if (skillDirs.length === 0) {
|
|
2688
2782
|
console.log(` ${id} (no SKILL.md found)`);
|
|
2689
2783
|
continue;
|
|
2690
2784
|
}
|
|
2691
2785
|
const names = skillDirs.map((dir) => {
|
|
2692
2786
|
try {
|
|
2693
|
-
return readFrontmatter(
|
|
2787
|
+
return readFrontmatter(join17(dir, "SKILL.md")).name;
|
|
2694
2788
|
} catch {
|
|
2695
2789
|
return dir;
|
|
2696
2790
|
}
|
|
@@ -2711,6 +2805,319 @@ async function status() {
|
|
|
2711
2805
|
console.log(` held in quarantine: ${heldCount}`);
|
|
2712
2806
|
}
|
|
2713
2807
|
|
|
2808
|
+
// src/evolve.ts
|
|
2809
|
+
import { createInterface as createInterface3 } from "readline/promises";
|
|
2810
|
+
import { createHash } from "crypto";
|
|
2811
|
+
import { cpSync as cpSync3, existsSync as existsSync14, lstatSync, mkdtempSync as mkdtempSync2, readFileSync as readFileSync10, rmSync as rmSync3 } from "fs";
|
|
2812
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
2813
|
+
import { basename as basename6, dirname as dirname6, join as join18, resolve as resolvePath2, sep } from "path";
|
|
2814
|
+
var SKILLOPT_BIN = "skillopt-sleep";
|
|
2815
|
+
var PRO_FEATURE_LABEL2 = "self-evolution via SkillOpt-Sleep (evolve) \u2014 re-gating, provenance, and the pending-ingestion queue";
|
|
2816
|
+
async function evolve(targetSkillDir, options = {}, deps = {}) {
|
|
2817
|
+
const exec = deps.exec ?? defaultExec;
|
|
2818
|
+
const json = options.json;
|
|
2819
|
+
const resolvedTargetDir = resolvePath2(targetSkillDir);
|
|
2820
|
+
const skillMdPath = join18(resolvedTargetDir, "SKILL.md");
|
|
2821
|
+
if (!existsSync14(skillMdPath)) {
|
|
2822
|
+
throw new UserError(`no SKILL.md found at ${skillMdPath} \u2014 evolve requires an existing, installed skill directory`);
|
|
2823
|
+
}
|
|
2824
|
+
const proStatus = proFeatureStatus(PRO_FEATURE_LABEL2);
|
|
2825
|
+
if (!proStatus.enabled) {
|
|
2826
|
+
throw new UserError(proStatus.notice ?? "evolve is a Pro feature \u2014 set SKILL_FORGE_LICENSE or config.licenseKey to a valid license to unlock.");
|
|
2827
|
+
}
|
|
2828
|
+
const notices = proStatus.notice ? [proStatus.notice] : [];
|
|
2829
|
+
const backend = options.backend ?? "mock";
|
|
2830
|
+
if (backend !== "mock") {
|
|
2831
|
+
const disclosure = `data boundary: this run uses the "${backend}" backend. Per SkillOpt-Sleep's own docs, harvesting itself is local and read-only, but a real backend sends truncated excerpts from harvested sessions and derived tasks to the provider you selected \u2014 this is not currently guaranteed to be secret-free.`;
|
|
2832
|
+
if (options.yes) {
|
|
2833
|
+
notices.push(`${disclosure} Proceeding (--yes).`);
|
|
2834
|
+
} else if (json) {
|
|
2835
|
+
throw new UserError(`${disclosure} Pass --yes to proceed \u2014 --json is non-interactive and cannot prompt for confirmation.`);
|
|
2836
|
+
} else {
|
|
2837
|
+
console.log(`[skill-forge] ${disclosure}`);
|
|
2838
|
+
const confirmFn = deps.confirm ?? defaultConfirm;
|
|
2839
|
+
const confirmed = await confirmFn("Proceed and send session data to this backend? [y/N] ");
|
|
2840
|
+
if (!confirmed) {
|
|
2841
|
+
throw new UserError("evolve cancelled \u2014 data-boundary disclosure was not accepted.");
|
|
2842
|
+
}
|
|
2843
|
+
notices.push(`${disclosure} Confirmed interactively.`);
|
|
2844
|
+
}
|
|
2845
|
+
} else {
|
|
2846
|
+
notices.push('using the offline "mock" backend \u2014 deterministic, no network calls, no session data leaves this machine.');
|
|
2847
|
+
}
|
|
2848
|
+
const project = resolvePath2(options.project ?? process.cwd());
|
|
2849
|
+
const present = await detectSkillopt(exec, project);
|
|
2850
|
+
if (!present) {
|
|
2851
|
+
throw new UserError(
|
|
2852
|
+
"`skillopt-sleep` not found on PATH. Install it with `pip install skillopt` (https://github.com/microsoft/SkillOpt) and re-run `skill-forge evolve`."
|
|
2853
|
+
);
|
|
2854
|
+
}
|
|
2855
|
+
const subcommand = options.dryRun ? "dry-run" : "run";
|
|
2856
|
+
const runArgs = [subcommand, "--project", project, "--target-skill-path", skillMdPath, "--json", "--backend", backend];
|
|
2857
|
+
if (options.lookbackHours !== void 0) {
|
|
2858
|
+
runArgs.push("--lookback-hours", String(options.lookbackHours));
|
|
2859
|
+
}
|
|
2860
|
+
const runResult = await exec(SKILLOPT_BIN, runArgs, { cwd: project });
|
|
2861
|
+
if (runResult.code !== 0) {
|
|
2862
|
+
throw new UserError(`skillopt-sleep ${subcommand} failed (exit ${runResult.code}): ${tail(runResult.stderr || runResult.stdout)}`);
|
|
2863
|
+
}
|
|
2864
|
+
let rawOutput;
|
|
2865
|
+
try {
|
|
2866
|
+
rawOutput = JSON.parse(runResult.stdout);
|
|
2867
|
+
} catch (err) {
|
|
2868
|
+
throw new UserError(`could not parse skillopt-sleep JSON output: ${err.message}`);
|
|
2869
|
+
}
|
|
2870
|
+
const parsed = validateRunOutput(rawOutput);
|
|
2871
|
+
if (!parsed.accepted || !parsed.staging_dir) {
|
|
2872
|
+
if (json) {
|
|
2873
|
+
console.log(JSON.stringify({ ok: true, accepted: parsed.accepted, stagingDir: parsed.staging_dir }, null, 2));
|
|
2874
|
+
} else {
|
|
2875
|
+
console.log("[skill-forge] skillopt-sleep produced no accepted proposal \u2014 nothing to re-gate or adopt.");
|
|
2876
|
+
}
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2879
|
+
const stagingRoot = join18(project, ".skillopt-sleep", "staging");
|
|
2880
|
+
const stagingDir = resolvePath2(parsed.staging_dir);
|
|
2881
|
+
if (stagingDir !== stagingRoot && !stagingDir.startsWith(stagingRoot + sep)) {
|
|
2882
|
+
throw new UserError(
|
|
2883
|
+
`skillopt-sleep reported a staging_dir outside the expected sandbox (${stagingRoot}): ${stagingDir} \u2014 refusing to use it`
|
|
2884
|
+
);
|
|
2885
|
+
}
|
|
2886
|
+
const proposedSkillMd = join18(stagingDir, "proposed_SKILL.md");
|
|
2887
|
+
const proposedClaudeMd = join18(stagingDir, "proposed_CLAUDE.md");
|
|
2888
|
+
const reportMdPath = join18(stagingDir, "report.md");
|
|
2889
|
+
if (!existsSync14(proposedSkillMd)) {
|
|
2890
|
+
throw new UserError(`skillopt-sleep reported an accepted proposal but ${proposedSkillMd} is missing`);
|
|
2891
|
+
}
|
|
2892
|
+
if (existsSync14(reportMdPath)) {
|
|
2893
|
+
notices.push(`SkillOpt-Sleep's full report: ${reportMdPath}`);
|
|
2894
|
+
}
|
|
2895
|
+
assertNotSymlink(proposedSkillMd, "proposed_SKILL.md");
|
|
2896
|
+
assertNotSymlink(proposedClaudeMd, "proposed_CLAUDE.md");
|
|
2897
|
+
const scannedSkillHash = sha256File(proposedSkillMd);
|
|
2898
|
+
const scannedClaudeHash = sha256File(proposedClaudeMd);
|
|
2899
|
+
const tempDir = mkdtempSync2(join18(tmpdir2(), "skill-forge-evolve-"));
|
|
2900
|
+
let profile;
|
|
2901
|
+
let safety;
|
|
2902
|
+
try {
|
|
2903
|
+
cpSync3(proposedSkillMd, join18(tempDir, "SKILL.md"));
|
|
2904
|
+
if (existsSync14(proposedClaudeMd)) {
|
|
2905
|
+
cpSync3(proposedClaudeMd, join18(tempDir, "CLAUDE.md"));
|
|
2906
|
+
}
|
|
2907
|
+
profile = asSkillProfile(profileSkill(tempDir), "for the evolved proposal");
|
|
2908
|
+
const strictness = loadConfig().strictness;
|
|
2909
|
+
safety = await scanSafety(tempDir, strictness);
|
|
2910
|
+
} finally {
|
|
2911
|
+
try {
|
|
2912
|
+
rmSync3(tempDir, { recursive: true, force: true });
|
|
2913
|
+
} catch (err) {
|
|
2914
|
+
console.error(`[skill-forge] failed to clean up temp re-gate dir ${tempDir}: ${err.message}`);
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
const safetyVerdict = safety.verdict;
|
|
2918
|
+
const gateResult = {
|
|
2919
|
+
profile,
|
|
2920
|
+
safety: {
|
|
2921
|
+
verdict: SAFETY_VERDICT_MAP[safetyVerdict],
|
|
2922
|
+
findings: mapSafetyFindings(safety.findings)
|
|
2923
|
+
},
|
|
2924
|
+
notices
|
|
2925
|
+
};
|
|
2926
|
+
printGateOutput(gateResult, json);
|
|
2927
|
+
if (options.dryRun) {
|
|
2928
|
+
logOutcome("[skill-forge] --dry-run: report only, nothing staged or adopted.", json);
|
|
2929
|
+
return;
|
|
2930
|
+
}
|
|
2931
|
+
const decision = options.yes || json ? yesDecision(safetyVerdict) : await promptDecision(safetyVerdict, "this evolution proposal");
|
|
2932
|
+
if (decision === "promote") {
|
|
2933
|
+
const manifest = readManifest(stagingDir);
|
|
2934
|
+
if (isAlreadyAdopted(stagingDir, manifest) && !options.force) {
|
|
2935
|
+
throw new UserError(
|
|
2936
|
+
`staging dir ${stagingDir} looks already adopted \u2014 its proposed content is byte-identical to the live skill (and memory, if present). Pass --force to re-adopt anyway.`
|
|
2937
|
+
);
|
|
2938
|
+
}
|
|
2939
|
+
if (sha256File(proposedSkillMd) !== scannedSkillHash || sha256File(proposedClaudeMd) !== scannedClaudeHash) {
|
|
2940
|
+
throw new UserError(`proposal changed after it was re-gated (staging dir: ${stagingDir}) \u2014 refusing to adopt`);
|
|
2941
|
+
}
|
|
2942
|
+
const adoptResult = await exec(SKILLOPT_BIN, ["adopt", "--staging", stagingDir, "--project", project], { cwd: project });
|
|
2943
|
+
if (adoptResult.code !== 0) {
|
|
2944
|
+
throw new UserError(`skillopt-sleep adopt failed (exit ${adoptResult.code}): ${tail(adoptResult.stderr || adoptResult.stdout)}`);
|
|
2945
|
+
}
|
|
2946
|
+
logOutcome(`[skill-forge] adopted -> ${resolvedTargetDir}`, json);
|
|
2947
|
+
const skillsRootForProvenance = dirname6(resolvedTargetDir);
|
|
2948
|
+
let ledgerPath;
|
|
2949
|
+
try {
|
|
2950
|
+
ledgerPath = appendProvenance(
|
|
2951
|
+
{
|
|
2952
|
+
name: profile.name ?? basename6(resolvedTargetDir),
|
|
2953
|
+
source: resolvedTargetDir,
|
|
2954
|
+
version: profile.version,
|
|
2955
|
+
license: profile.license,
|
|
2956
|
+
verb: "DEFER",
|
|
2957
|
+
target: resolvedTargetDir,
|
|
2958
|
+
took: `self-evolved via SkillOpt-Sleep (staging: ${stagingDir})`,
|
|
2959
|
+
notes: `self-evolved via SkillOpt-Sleep; re-gated (verdict: ${safetyVerdict})`
|
|
2960
|
+
},
|
|
2961
|
+
skillsRootForProvenance
|
|
2962
|
+
);
|
|
2963
|
+
} catch (err) {
|
|
2964
|
+
throw new UserError(
|
|
2965
|
+
`adopt succeeded and the live skill at ${resolvedTargetDir} has already changed, but recording the provenance entry failed: ${err.message}`
|
|
2966
|
+
);
|
|
2967
|
+
}
|
|
2968
|
+
logOutcome(`[skill-forge] provenance recorded -> ${ledgerPath}`, json);
|
|
2969
|
+
try {
|
|
2970
|
+
appendQueueEntry({
|
|
2971
|
+
source: resolvedTargetDir,
|
|
2972
|
+
sourceType: "local",
|
|
2973
|
+
installedPath: resolvedTargetDir,
|
|
2974
|
+
quarantinePath: stagingDir,
|
|
2975
|
+
gate: {
|
|
2976
|
+
license: profile.license ?? null,
|
|
2977
|
+
safetyVerdict,
|
|
2978
|
+
safetyFindings: safety.findings.map(formatSafetyFinding),
|
|
2979
|
+
overlapTop: [],
|
|
2980
|
+
suggestedVerb: null
|
|
2981
|
+
},
|
|
2982
|
+
status: "pending",
|
|
2983
|
+
artifactType: "skill",
|
|
2984
|
+
origin: "evolve"
|
|
2985
|
+
});
|
|
2986
|
+
} catch (err) {
|
|
2987
|
+
throw new UserError(
|
|
2988
|
+
`adopt succeeded and the live skill at ${resolvedTargetDir} has already changed (provenance was recorded), but appending the pending-ingestion queue entry failed: ${err.message}`
|
|
2989
|
+
);
|
|
2990
|
+
}
|
|
2991
|
+
logOutcome("[skill-forge] queued for ingestion review", json);
|
|
2992
|
+
} else if (decision === "reject") {
|
|
2993
|
+
try {
|
|
2994
|
+
rmSync3(stagingDir, { recursive: true, force: true });
|
|
2995
|
+
} catch (err) {
|
|
2996
|
+
throw new UserError(
|
|
2997
|
+
`rejected the proposal, but removing the staging dir ${stagingDir} failed: ${err.message} \u2014 remove it manually.`
|
|
2998
|
+
);
|
|
2999
|
+
}
|
|
3000
|
+
logOutcome("[skill-forge] rejected \u2014 staging dir removed", json);
|
|
3001
|
+
if (safetyVerdict === "block") process.exitCode = 1;
|
|
3002
|
+
} else {
|
|
3003
|
+
logOutcome(`[skill-forge] held \u2014 staging dir left in place -> ${stagingDir}`, json);
|
|
3004
|
+
if (existsSync14(reportMdPath)) {
|
|
3005
|
+
logOutcome(`[skill-forge] see ${reportMdPath} for the full evidence report`, json);
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
async function defaultConfirm(question) {
|
|
3010
|
+
const rl = createInterface3({ input: process.stdin, output: process.stdout });
|
|
3011
|
+
try {
|
|
3012
|
+
process.stdout.write(question);
|
|
3013
|
+
for await (const raw of rl) {
|
|
3014
|
+
return /^y(es)?$/i.test(raw.trim());
|
|
3015
|
+
}
|
|
3016
|
+
return false;
|
|
3017
|
+
} finally {
|
|
3018
|
+
rl.close();
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
async function detectSkillopt(exec, cwd) {
|
|
3022
|
+
try {
|
|
3023
|
+
const result = await exec(SKILLOPT_BIN, ["--help"], { cwd });
|
|
3024
|
+
return result.code === 0;
|
|
3025
|
+
} catch {
|
|
3026
|
+
return false;
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
function tail(text, maxLines = 20) {
|
|
3030
|
+
return text.trim().split("\n").slice(-maxLines).join("\n");
|
|
3031
|
+
}
|
|
3032
|
+
function validateRunOutput(raw) {
|
|
3033
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
3034
|
+
throw new UserError(`skillopt-sleep produced unexpected JSON output (expected an object): ${JSON.stringify(raw)}`);
|
|
3035
|
+
}
|
|
3036
|
+
const obj = raw;
|
|
3037
|
+
const stagingDirRaw = obj.staging_dir;
|
|
3038
|
+
if (stagingDirRaw !== null && stagingDirRaw !== void 0 && typeof stagingDirRaw !== "string") {
|
|
3039
|
+
throw new UserError(
|
|
3040
|
+
`skillopt-sleep produced an unexpected "staging_dir" (expected a string or null): ${JSON.stringify(stagingDirRaw)}`
|
|
3041
|
+
);
|
|
3042
|
+
}
|
|
3043
|
+
return { accepted: Boolean(obj.accepted), staging_dir: stagingDirRaw ?? null };
|
|
3044
|
+
}
|
|
3045
|
+
function readManifest(stagingDir) {
|
|
3046
|
+
const manifestPath = join18(stagingDir, "manifest.json");
|
|
3047
|
+
let raw;
|
|
3048
|
+
try {
|
|
3049
|
+
raw = readFileSync10(manifestPath, "utf-8");
|
|
3050
|
+
} catch (err) {
|
|
3051
|
+
throw new UserError(`could not read staging manifest ${manifestPath}: ${err.message}`);
|
|
3052
|
+
}
|
|
3053
|
+
let parsed;
|
|
3054
|
+
try {
|
|
3055
|
+
parsed = JSON.parse(raw);
|
|
3056
|
+
} catch (err) {
|
|
3057
|
+
throw new UserError(`could not parse staging manifest ${manifestPath}: ${err.message}`);
|
|
3058
|
+
}
|
|
3059
|
+
return validateManifest(parsed, manifestPath);
|
|
3060
|
+
}
|
|
3061
|
+
function validateManifest(raw, manifestPath) {
|
|
3062
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
3063
|
+
throw new UserError(`staging manifest ${manifestPath} has an unexpected shape (expected an object)`);
|
|
3064
|
+
}
|
|
3065
|
+
const obj = raw;
|
|
3066
|
+
if (typeof obj.live_skill_path !== "string") {
|
|
3067
|
+
throw new UserError(`staging manifest ${manifestPath} is missing a valid "live_skill_path" string`);
|
|
3068
|
+
}
|
|
3069
|
+
if (obj.live_memory_path !== void 0 && obj.live_memory_path !== null && typeof obj.live_memory_path !== "string") {
|
|
3070
|
+
throw new UserError(`staging manifest ${manifestPath} has an unexpected "live_memory_path" (expected a string or null)`);
|
|
3071
|
+
}
|
|
3072
|
+
if (typeof obj.has_skill !== "boolean" || typeof obj.has_memory !== "boolean") {
|
|
3073
|
+
throw new UserError(`staging manifest ${manifestPath} is missing valid "has_skill"/"has_memory" booleans`);
|
|
3074
|
+
}
|
|
3075
|
+
return {
|
|
3076
|
+
live_skill_path: obj.live_skill_path,
|
|
3077
|
+
live_memory_path: obj.live_memory_path ?? null,
|
|
3078
|
+
has_skill: obj.has_skill,
|
|
3079
|
+
has_memory: obj.has_memory
|
|
3080
|
+
};
|
|
3081
|
+
}
|
|
3082
|
+
function filesIdentical(a, b) {
|
|
3083
|
+
if (!existsSync14(a) || !existsSync14(b)) return false;
|
|
3084
|
+
return readFileSync10(a).equals(readFileSync10(b));
|
|
3085
|
+
}
|
|
3086
|
+
function isAlreadyAdopted(stagingDir, manifest) {
|
|
3087
|
+
const proposedSkillMd = join18(stagingDir, "proposed_SKILL.md");
|
|
3088
|
+
const proposedClaudeMd = join18(stagingDir, "proposed_CLAUDE.md");
|
|
3089
|
+
let sawComparison = false;
|
|
3090
|
+
if (existsSync14(proposedSkillMd)) {
|
|
3091
|
+
sawComparison = true;
|
|
3092
|
+
if (!filesIdentical(proposedSkillMd, manifest.live_skill_path)) return false;
|
|
3093
|
+
}
|
|
3094
|
+
if (existsSync14(proposedClaudeMd) && manifest.live_memory_path) {
|
|
3095
|
+
sawComparison = true;
|
|
3096
|
+
if (!filesIdentical(proposedClaudeMd, manifest.live_memory_path)) return false;
|
|
3097
|
+
}
|
|
3098
|
+
return sawComparison;
|
|
3099
|
+
}
|
|
3100
|
+
function assertNotSymlink(path, label) {
|
|
3101
|
+
let stat;
|
|
3102
|
+
try {
|
|
3103
|
+
stat = lstatSync(path);
|
|
3104
|
+
} catch {
|
|
3105
|
+
return;
|
|
3106
|
+
}
|
|
3107
|
+
if (stat.isSymbolicLink()) {
|
|
3108
|
+
throw new UserError(`refusing to re-gate ${label}: ${path} is a symlink (a staged proposal must be a plain file)`);
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
function sha256File(path) {
|
|
3112
|
+
if (!existsSync14(path)) return null;
|
|
3113
|
+
return createHash("sha256").update(readFileSync10(path)).digest("hex");
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
// src/commands/evolve.ts
|
|
3117
|
+
async function evolveCommand(targetSkillDir, options = {}) {
|
|
3118
|
+
await evolve(targetSkillDir, options);
|
|
3119
|
+
}
|
|
3120
|
+
|
|
2714
3121
|
// src/cli.ts
|
|
2715
3122
|
var require2 = createRequire(import.meta.url);
|
|
2716
3123
|
var pkg = require2("../package.json");
|
|
@@ -2720,6 +3127,13 @@ function parseArtifact(value) {
|
|
|
2720
3127
|
}
|
|
2721
3128
|
return value;
|
|
2722
3129
|
}
|
|
3130
|
+
function parseLookbackHours(value) {
|
|
3131
|
+
const n = Number(value);
|
|
3132
|
+
if (value.trim() === "" || !Number.isFinite(n) || n < 0) {
|
|
3133
|
+
throw new UserError(`--lookback-hours must be a non-negative number (got "${value}")`);
|
|
3134
|
+
}
|
|
3135
|
+
return n;
|
|
3136
|
+
}
|
|
2723
3137
|
var program = new Command();
|
|
2724
3138
|
program.name("skill-forge").description("The supply-chain gate for agent skills").version(pkg.version);
|
|
2725
3139
|
program.command("add <source>").description("Quarantine-install a skill and run it through the safety gate").option("-y, --yes", "skip the interactive prompt; honor the gate verdict automatically").option("-t, --target <dir>", "skills root to promote into (defaults to config defaultTarget, then skillsRoots[0])").option("--json", "print the gate result as JSON instead of the terminal report").option("--ingest", "after a successful promote, hand off the bundled ingest prompt to a coding agent").option("--artifact <type>", 'gate as a "skill" or an "mcp" server (default: skill)', parseArtifact).option(
|
|
@@ -2729,6 +3143,7 @@ program.command("add <source>").description("Quarantine-install a skill and run
|
|
|
2729
3143
|
program.command("scan <source>").description("Run the safety gate against a skill without installing it").option("--json", "print the gate result as JSON instead of the terminal report").option("--artifact <type>", 'gate as a "skill" or an "mcp" server (default: skill)', parseArtifact).action(scan);
|
|
2730
3144
|
program.command("list").description("List skills currently in quarantine").action(list);
|
|
2731
3145
|
program.command("status").description("Show skill-forge configuration and quarantine status").action(status);
|
|
3146
|
+
program.command("evolve <target-skill-dir>").description("Self-evolve a skill via SkillOpt-Sleep, re-gate the proposal, then promote/hold/reject (Pro)").option("--project <dir>", "project dir passed to SkillOpt-Sleep as --project (defaults to cwd)").option("--dry-run", "SkillOpt-Sleep dry-run: report only, nothing staged").option("--backend <name>", "SkillOpt-Sleep backend (default: mock, offline \u2014 a real backend requires --yes or interactive confirmation)").option("--lookback-hours <n>", "hours of session history for SkillOpt-Sleep to harvest", parseLookbackHours).option("-y, --yes", "skip interactive prompts; honor the re-gate verdict automatically").option("--json", "print the re-gate result as JSON instead of the terminal report").option("--force", "allow re-adopting a staging dir whose proposed content already matches the live skill").action(evolveCommand);
|
|
2732
3147
|
program.command("init").description("Detect installed agents and set gate targets, default promotion target, and handoff agent").option("--defaults", "non-interactive: use all detected agents as gate targets, the first as the default target").option("--list", "print detected agent skill roots and exit \u2014 no writes").action(init);
|
|
2733
3148
|
try {
|
|
2734
3149
|
await program.parseAsync();
|