agentic-workflow-manager 3.0.1 → 3.1.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/dist/src/commands/export.js +48 -0
- package/dist/src/core/discovery.js +9 -3
- package/dist/src/core/export/index.js +51 -0
- package/dist/src/core/export/pack.js +58 -0
- package/dist/src/core/export/resolve.js +77 -0
- package/dist/src/core/export/transform.js +57 -0
- package/dist/src/core/export/types.js +2 -0
- package/dist/src/index.js +2 -0
- package/dist/tests/commands/export.test.js +64 -0
- package/dist/tests/core/export/engine.test.js +103 -0
- package/dist/tests/core/export/pack.test.js +122 -0
- package/dist/tests/core/export/resolve.test.js +78 -0
- package/dist/tests/core/export/transform.test.js +74 -0
- package/package.json +1 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.runExportCommand = runExportCommand;
|
|
7
|
+
exports.registerExportCommand = registerExportCommand;
|
|
8
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const export_1 = require("../core/export");
|
|
11
|
+
function runExportCommand(name, flags, deps = {}) {
|
|
12
|
+
const log = deps.log ?? console.log;
|
|
13
|
+
const opts = {
|
|
14
|
+
name,
|
|
15
|
+
target: flags.target,
|
|
16
|
+
out: flags.out,
|
|
17
|
+
roots: deps.roots,
|
|
18
|
+
zip: deps.zip,
|
|
19
|
+
};
|
|
20
|
+
const summary = (0, export_1.runExport)(opts);
|
|
21
|
+
log(picocolors_1.default.dim(`Exported ${summary.kind} "${name}"`));
|
|
22
|
+
for (const e of summary.exported) {
|
|
23
|
+
log(picocolors_1.default.green(`✓ ${e.name}`) + picocolors_1.default.dim(` → ${e.zip ?? e.dir}`));
|
|
24
|
+
}
|
|
25
|
+
if (summary.skipped.length > 0) {
|
|
26
|
+
log(picocolors_1.default.dim(`Skipped (not portable): ${summary.skipped.join(', ')}`));
|
|
27
|
+
}
|
|
28
|
+
if (!summary.zipAvailable) {
|
|
29
|
+
log(picocolors_1.default.yellow('zip binary not found — folders were written without archives.'));
|
|
30
|
+
log(picocolors_1.default.dim(`Compress manually, e.g.: cd ${summary.outDir} && zip -r <skill>.zip <skill>`));
|
|
31
|
+
}
|
|
32
|
+
log(picocolors_1.default.dim(`Output: ${path_1.default.resolve(summary.outDir)}`));
|
|
33
|
+
}
|
|
34
|
+
function registerExportCommand(program) {
|
|
35
|
+
program.command('export <name>')
|
|
36
|
+
.description('Export a bundle or skill as claude.ai-uploadable custom skill artifacts (folder + zip)')
|
|
37
|
+
.option('--target <target>', 'export target', 'claude-ai')
|
|
38
|
+
.option('--out <dir>', 'output directory (default: ./awm-export)')
|
|
39
|
+
.action((name, flags) => {
|
|
40
|
+
try {
|
|
41
|
+
runExportCommand(name, flags);
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
console.error(picocolors_1.default.red(e instanceof Error ? e.message : String(e)));
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.matchFrontmatterBlock = matchFrontmatterBlock;
|
|
6
7
|
exports.readArtifactDescription = readArtifactDescription;
|
|
7
8
|
exports.discoverSkills = discoverSkills;
|
|
8
9
|
exports.discoverWorkflows = discoverWorkflows;
|
|
@@ -11,13 +12,18 @@ exports.discoverAgents = discoverAgents;
|
|
|
11
12
|
const fs_1 = __importDefault(require("fs"));
|
|
12
13
|
const path_1 = __importDefault(require("path"));
|
|
13
14
|
const registries_1 = require("./registries");
|
|
15
|
+
/** Extracts the raw frontmatter text (between the --- delimiters), or null if the block is missing/malformed. CRLF-tolerant. */
|
|
16
|
+
function matchFrontmatterBlock(raw) {
|
|
17
|
+
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
18
|
+
return fmMatch ? fmMatch[1] : null;
|
|
19
|
+
}
|
|
14
20
|
function readArtifactDescription(filePath) {
|
|
15
21
|
try {
|
|
16
22
|
const raw = fs_1.default.readFileSync(filePath, 'utf-8');
|
|
17
|
-
const
|
|
18
|
-
if (
|
|
23
|
+
const frontmatter = matchFrontmatterBlock(raw);
|
|
24
|
+
if (frontmatter === null)
|
|
19
25
|
return '';
|
|
20
|
-
const line =
|
|
26
|
+
const line = frontmatter
|
|
21
27
|
.split(/\r?\n/)
|
|
22
28
|
.find((l) => /^description\s*:/.test(l));
|
|
23
29
|
if (!line)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.EXPORT_TARGETS = void 0;
|
|
7
|
+
exports.runExport = runExport;
|
|
8
|
+
// cli/src/core/export/index.ts
|
|
9
|
+
//
|
|
10
|
+
// Orquestación del export (issue #9): resolve → adapt (override verbatim R3,
|
|
11
|
+
// o transform mecánico R3.1) → pack. Opera 100% offline (R5.2): solo fs local.
|
|
12
|
+
const fs_1 = __importDefault(require("fs"));
|
|
13
|
+
const path_1 = __importDefault(require("path"));
|
|
14
|
+
const registries_1 = require("../registries");
|
|
15
|
+
const resolve_1 = require("./resolve");
|
|
16
|
+
const transform_1 = require("./transform");
|
|
17
|
+
const pack_1 = require("./pack");
|
|
18
|
+
exports.EXPORT_TARGETS = ['claude-ai'];
|
|
19
|
+
function runExport(opts) {
|
|
20
|
+
const target = opts.target ?? 'claude-ai';
|
|
21
|
+
if (!exports.EXPORT_TARGETS.includes(target)) {
|
|
22
|
+
throw new Error(`Unknown export target "${target}". Valid targets: ${exports.EXPORT_TARGETS.join(', ')}.`);
|
|
23
|
+
}
|
|
24
|
+
const roots = opts.roots ?? (0, registries_1.contentRoots)();
|
|
25
|
+
const outDir = path_1.default.join(opts.out ?? path_1.default.join(process.cwd(), 'awm-export'), target);
|
|
26
|
+
const resolution = (0, resolve_1.resolveExport)(opts.name, roots);
|
|
27
|
+
fs_1.default.mkdirSync(outDir, { recursive: true });
|
|
28
|
+
const exported = [];
|
|
29
|
+
let zipAvailable = true;
|
|
30
|
+
for (const skill of resolution.skills) {
|
|
31
|
+
let adapted;
|
|
32
|
+
if (skill.overridePath) {
|
|
33
|
+
adapted = fs_1.default.readFileSync(skill.overridePath, 'utf-8'); // R3: verbatim
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
const canonical = path_1.default.join(skill.dir, 'SKILL.md');
|
|
37
|
+
const raw = fs_1.default.readFileSync(canonical, 'utf-8');
|
|
38
|
+
try {
|
|
39
|
+
adapted = (0, transform_1.claudeAiTransform)(raw, skill.name);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
throw new Error(`${canonical}: ${e instanceof Error ? e.message : String(e)}`); // R3.4 cita el archivo
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const packed = (0, pack_1.packSkill)({ name: skill.name, adaptedSkillMd: adapted, srcDir: skill.dir, targetRoot: outDir, zip: opts.zip });
|
|
46
|
+
if (packed.zipMissing)
|
|
47
|
+
zipAvailable = false;
|
|
48
|
+
exported.push({ name: skill.name, dir: packed.dir, zip: packed.zip });
|
|
49
|
+
}
|
|
50
|
+
return { outDir, kind: resolution.kind, exported, skipped: resolution.skipped, zipAvailable };
|
|
51
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.defaultZip = void 0;
|
|
7
|
+
exports.packSkill = packSkill;
|
|
8
|
+
// cli/src/core/export/pack.ts
|
|
9
|
+
//
|
|
10
|
+
// Escritura determinística del artefacto (R4: limpia su propio subárbol antes
|
|
11
|
+
// de escribir) + zip por capas con binario del sistema (R4.1/R4.2). ZipFn es
|
|
12
|
+
// inyectable para tests.
|
|
13
|
+
const fs_1 = __importDefault(require("fs"));
|
|
14
|
+
const path_1 = __importDefault(require("path"));
|
|
15
|
+
const child_process_1 = require("child_process");
|
|
16
|
+
/** Refuses symlinks anywhere in the tree — copying/zipping them could dereference
|
|
17
|
+
* into content outside the registry (info-leak) or embed a broken/unexpected
|
|
18
|
+
* link for the recipient. Exported artifacts are plain files only. */
|
|
19
|
+
function assertNoSymlinks(dir) {
|
|
20
|
+
for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
|
|
21
|
+
const full = path_1.default.join(dir, entry.name);
|
|
22
|
+
if (entry.isSymbolicLink()) {
|
|
23
|
+
throw new Error(`Refusing to export "${full}": symlinks are not supported in exported artifacts (could leak file content via zip dereferencing, or resolve unexpectedly for the recipient).`);
|
|
24
|
+
}
|
|
25
|
+
if (entry.isDirectory())
|
|
26
|
+
assertNoSymlinks(full);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Capa 1: binario `zip` del sistema. ENOENT → missing (capa 2: carpeta). */
|
|
30
|
+
const defaultZip = (cwd, zipName, folderName) => {
|
|
31
|
+
const r = (0, child_process_1.spawnSync)('zip', ['-r', '-q', zipName, folderName], { cwd });
|
|
32
|
+
if (r.error && r.error.code === 'ENOENT') {
|
|
33
|
+
return { ok: false, missing: true };
|
|
34
|
+
}
|
|
35
|
+
return { ok: r.status === 0, missing: false };
|
|
36
|
+
};
|
|
37
|
+
exports.defaultZip = defaultZip;
|
|
38
|
+
function packSkill(opts) {
|
|
39
|
+
const zip = opts.zip ?? exports.defaultZip;
|
|
40
|
+
const skillOut = path_1.default.join(opts.targetRoot, opts.name);
|
|
41
|
+
const zipPath = path_1.default.join(opts.targetRoot, `${opts.name}.zip`);
|
|
42
|
+
// R4: determinismo — el re-export limpia su propio subárbol primero.
|
|
43
|
+
fs_1.default.rmSync(skillOut, { recursive: true, force: true });
|
|
44
|
+
fs_1.default.rmSync(zipPath, { force: true });
|
|
45
|
+
fs_1.default.mkdirSync(skillOut, { recursive: true });
|
|
46
|
+
fs_1.default.writeFileSync(path_1.default.join(skillOut, 'SKILL.md'), opts.adaptedSkillMd);
|
|
47
|
+
const refs = path_1.default.join(opts.srcDir, 'references');
|
|
48
|
+
if (fs_1.default.existsSync(refs)) {
|
|
49
|
+
assertNoSymlinks(refs);
|
|
50
|
+
fs_1.default.cpSync(refs, path_1.default.join(skillOut, 'references'), { recursive: true }); // R3.2 byte-idéntico
|
|
51
|
+
}
|
|
52
|
+
const zr = zip(opts.targetRoot, `${opts.name}.zip`, opts.name);
|
|
53
|
+
if (zr.missing)
|
|
54
|
+
return { dir: skillOut, zip: null, zipMissing: true };
|
|
55
|
+
if (!zr.ok)
|
|
56
|
+
throw new Error(`zip failed for skill "${opts.name}" (non-zero exit).`);
|
|
57
|
+
return { dir: skillOut, zip: zipPath, zipMissing: false };
|
|
58
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.resolveExport = resolveExport;
|
|
7
|
+
// cli/src/core/export/resolve.ts
|
|
8
|
+
//
|
|
9
|
+
// Resolución <nombre> → skills a exportar (R1/R1.1) con gate de portabilidad
|
|
10
|
+
// (R2.x) y consistencia de override (R3.3). Lee SIEMPRE de content roots del
|
|
11
|
+
// registry instalado — nunca de ~/.claude/skills.
|
|
12
|
+
const fs_1 = __importDefault(require("fs"));
|
|
13
|
+
const path_1 = __importDefault(require("path"));
|
|
14
|
+
const bundles_1 = require("../bundles");
|
|
15
|
+
const discovery_1 = require("../discovery");
|
|
16
|
+
const OVERRIDE_FILE = 'port.claude-ai.md';
|
|
17
|
+
/** portable: true en el frontmatter (bloque --- inicial), CRLF-tolerant como readArtifactDescription en discovery.ts. */
|
|
18
|
+
function isPortable(skillMd) {
|
|
19
|
+
const frontmatter = (0, discovery_1.matchFrontmatterBlock)(skillMd);
|
|
20
|
+
if (frontmatter === null)
|
|
21
|
+
return false;
|
|
22
|
+
return /^portable\s*:\s*true\s*$/m.test(frontmatter);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Delegates skill location to discoverSkills(roots) so the SAME collision/override
|
|
26
|
+
* contract used by bundles/workflows/agents applies here (R3): a same-named skill in
|
|
27
|
+
* two roots throws unless the later root's awm-registry.json declares it in "overrides",
|
|
28
|
+
* in which case the later root's version wins. Locating manually (first-root-wins) would
|
|
29
|
+
* silently resolve to the wrong registry's version — see Finding 1.
|
|
30
|
+
*/
|
|
31
|
+
function locate(skillName, roots) {
|
|
32
|
+
const found = (0, discovery_1.discoverSkills)(roots).find((s) => s.name === skillName);
|
|
33
|
+
if (!found)
|
|
34
|
+
return null;
|
|
35
|
+
const dir = found.path;
|
|
36
|
+
const skillFile = path_1.default.join(dir, 'SKILL.md');
|
|
37
|
+
const overridePath = fs_1.default.existsSync(path_1.default.join(dir, OVERRIDE_FILE))
|
|
38
|
+
? path_1.default.join(dir, OVERRIDE_FILE) : null;
|
|
39
|
+
return { name: skillName, dir, portable: isPortable(fs_1.default.readFileSync(skillFile, 'utf-8')), overridePath };
|
|
40
|
+
}
|
|
41
|
+
/** R3.3: un override declara intención de export; sin portable es contrato a medias. */
|
|
42
|
+
function assertOverrideConsistency(s) {
|
|
43
|
+
if (s.overridePath && !s.portable) {
|
|
44
|
+
throw new Error(`Inconsistent metadata for skill "${s.name}": ${OVERRIDE_FILE} exists but SKILL.md does not declare portable: true.`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function resolveExport(requested, roots) {
|
|
48
|
+
const bundles = (0, bundles_1.discoverAllBundles)(roots);
|
|
49
|
+
if (bundles.some((b) => b.name === requested)) {
|
|
50
|
+
const skills = [];
|
|
51
|
+
const skipped = [];
|
|
52
|
+
for (const name of (0, bundles_1.resolveBundleSkills)(requested, bundles)) {
|
|
53
|
+
const s = locate(name, roots);
|
|
54
|
+
if (!s)
|
|
55
|
+
throw new Error(`Bundle "${requested}" lists skill "${name}" but no content root contains skills/${name}/SKILL.md.`);
|
|
56
|
+
assertOverrideConsistency(s);
|
|
57
|
+
if (s.portable)
|
|
58
|
+
skills.push(s);
|
|
59
|
+
else
|
|
60
|
+
skipped.push(s.name);
|
|
61
|
+
}
|
|
62
|
+
if (skills.length === 0) {
|
|
63
|
+
throw new Error(`Bundle "${requested}" has no portable skills — nothing to export. Mark skills with portable: true in their frontmatter.`);
|
|
64
|
+
}
|
|
65
|
+
return { kind: 'bundle', requested, skills, skipped: skipped.sort() };
|
|
66
|
+
}
|
|
67
|
+
const single = locate(requested, roots);
|
|
68
|
+
if (!single) {
|
|
69
|
+
const available = bundles.map((b) => b.name).join(', ') || '(none)';
|
|
70
|
+
throw new Error(`"${requested}" is neither a bundle nor a skill in any content root. Available bundles: ${available}.`);
|
|
71
|
+
}
|
|
72
|
+
assertOverrideConsistency(single);
|
|
73
|
+
if (!single.portable) {
|
|
74
|
+
throw new Error(`Skill "${requested}" is not portable (no portable: true in its frontmatter) — it likely depends on filesystem/git and would break on claude.ai.`);
|
|
75
|
+
}
|
|
76
|
+
return { kind: 'skill', requested, skills: [single], skipped: [] };
|
|
77
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFERENCE_LINE = void 0;
|
|
4
|
+
exports.claudeAiTransform = claudeAiTransform;
|
|
5
|
+
// cli/src/core/export/transform.ts
|
|
6
|
+
//
|
|
7
|
+
// Transform mecánico claude.ai (R3.1): función pura string → string.
|
|
8
|
+
// Frontmatter line-based plano (los SKILL.md del baseline usan claves de una
|
|
9
|
+
// línea) — sin parser YAML a propósito (YAGNI, cero deps).
|
|
10
|
+
const DEFERENCE_LINE = (skillName) => `In environments with AWM installed (Claude Code), defer to the registry's ${skillName} skill — this port is for environments without filesystem access.`;
|
|
11
|
+
exports.DEFERENCE_LINE = DEFERENCE_LINE;
|
|
12
|
+
function claudeAiTransform(skillMd, skillName) {
|
|
13
|
+
// \r?\n-tolerant, same rationale as readArtifactDescription in discovery.ts:
|
|
14
|
+
// SKILL.md files may be CRLF-terminated and that's still valid frontmatter.
|
|
15
|
+
const startMatch = skillMd.match(/^---\r?\n/);
|
|
16
|
+
if (!startMatch) {
|
|
17
|
+
throw new Error('missing frontmatter block (file must start with ---)');
|
|
18
|
+
}
|
|
19
|
+
const startLen = startMatch[0].length;
|
|
20
|
+
const endMatch = skillMd.slice(startLen).match(/\r?\n---\r?\n/);
|
|
21
|
+
if (!endMatch || endMatch.index === undefined) {
|
|
22
|
+
throw new Error('unterminated frontmatter block (closing --- not found)');
|
|
23
|
+
}
|
|
24
|
+
const end = startLen + endMatch.index;
|
|
25
|
+
const body = skillMd.slice(end + endMatch[0].length);
|
|
26
|
+
const fmLines = skillMd.slice(startLen, end).split(/\r?\n/)
|
|
27
|
+
.filter((l) => !/^(version|portable):/.test(l));
|
|
28
|
+
const descIdx = fmLines.findIndex((l) => /^description:/.test(l));
|
|
29
|
+
if (descIdx === -1) {
|
|
30
|
+
throw new Error('frontmatter has no description field');
|
|
31
|
+
}
|
|
32
|
+
const descLine = fmLines[descIdx];
|
|
33
|
+
const value = descLine.slice('description:'.length).trim();
|
|
34
|
+
if (value === '' || value === '>' || value === '|' || value.startsWith('>') || value.startsWith('|')) {
|
|
35
|
+
throw new Error('description must be single-line (block scalars are not supported by the export transform)');
|
|
36
|
+
}
|
|
37
|
+
const deference = (0, exports.DEFERENCE_LINE)(skillName);
|
|
38
|
+
// Quote-style detection mirrors readArtifactDescription in discovery.ts: both
|
|
39
|
+
// single- and double-quoted scalars are first-class, and we work off the
|
|
40
|
+
// trimmed value so trailing whitespace after a closing quote doesn't fool us.
|
|
41
|
+
const isDoubleQuoted = value.length >= 2 && value.startsWith('"') && value.endsWith('"');
|
|
42
|
+
const isSingleQuoted = value.length >= 2 && value.startsWith("'") && value.endsWith("'");
|
|
43
|
+
if ((value.startsWith('"') || value.startsWith("'")) && !isDoubleQuoted && !isSingleQuoted) {
|
|
44
|
+
throw new Error('description has trailing content after its closing quote (e.g. an inline comment) — not supported by the export transform; remove the comment or use a port.claude-ai.md override');
|
|
45
|
+
}
|
|
46
|
+
// YAML single-quoted scalars escape a literal ' by doubling it (''); the
|
|
47
|
+
// deference text ("...registry's..." — see DEFERENCE_LINE) contains an
|
|
48
|
+
// apostrophe, so it must be escaped before splicing into a single-quoted
|
|
49
|
+
// description or it would prematurely close the YAML string.
|
|
50
|
+
const newValue = isDoubleQuoted
|
|
51
|
+
? `${value.slice(0, -1)} ${deference}"`
|
|
52
|
+
: isSingleQuoted
|
|
53
|
+
? `${value.slice(0, -1)} ${deference.replace(/'/g, "''")}'`
|
|
54
|
+
: `${value} ${deference}`;
|
|
55
|
+
fmLines[descIdx] = `description: ${newValue}`;
|
|
56
|
+
return `---\n${fmLines.join('\n')}\n---\n${body}`;
|
|
57
|
+
}
|
package/dist/src/index.js
CHANGED
|
@@ -34,6 +34,7 @@ const doctor_1 = require("./commands/doctor");
|
|
|
34
34
|
const init_1 = require("./commands/init");
|
|
35
35
|
const registry_1 = require("./commands/registry");
|
|
36
36
|
const pin_1 = require("./commands/pin");
|
|
37
|
+
const export_1 = require("./commands/export");
|
|
37
38
|
const profile_pins_1 = require("./core/profile-pins");
|
|
38
39
|
const update_check_1 = require("./core/update-check");
|
|
39
40
|
const paths_1 = require("./core/paths");
|
|
@@ -801,4 +802,5 @@ miroCmd.command('sync <storyMapPath>')
|
|
|
801
802
|
(0, init_1.registerInitCommand)(program);
|
|
802
803
|
(0, registry_1.registerRegistryCommand)(program);
|
|
803
804
|
(0, pin_1.registerPinCommands)(program);
|
|
805
|
+
(0, export_1.registerExportCommand)(program);
|
|
804
806
|
program.parse();
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const export_1 = require("../../src/commands/export");
|
|
10
|
+
const okZip = (cwd, zipName) => {
|
|
11
|
+
fs_1.default.writeFileSync(path_1.default.join(cwd, zipName), 'fake-zip');
|
|
12
|
+
return { ok: true, missing: false };
|
|
13
|
+
};
|
|
14
|
+
function makeRoot() {
|
|
15
|
+
const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-cmd-root-'));
|
|
16
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'bundles/dev'), { recursive: true });
|
|
17
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'catalog.json'), JSON.stringify({
|
|
18
|
+
version: 1,
|
|
19
|
+
bundles: [{ name: 'dev', source: './bundles/dev', version: '1.0.0', scope: 'baseline' }],
|
|
20
|
+
}));
|
|
21
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'bundles/dev/bundle.json'), JSON.stringify({
|
|
22
|
+
name: 'dev', version: '1.0.0', scope: 'baseline', dependsOn: [],
|
|
23
|
+
skills: ['proc-skill', { name: 'mermaid', onSignal: true }], workflows: [], agents: [],
|
|
24
|
+
}));
|
|
25
|
+
const mk = (name, fm) => {
|
|
26
|
+
const dir = path_1.default.join(root, 'skills', name);
|
|
27
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
28
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, 'SKILL.md'), `---\n${fm.join('\n')}\n---\nBody.\n`);
|
|
29
|
+
};
|
|
30
|
+
mk('proc-skill', ['name: proc-skill', 'description: "P."']);
|
|
31
|
+
mk('mermaid', ['name: mermaid', 'portable: true', 'description: "D."']);
|
|
32
|
+
return root;
|
|
33
|
+
}
|
|
34
|
+
describe('runExportCommand (salida al usuario)', () => {
|
|
35
|
+
let root;
|
|
36
|
+
let out;
|
|
37
|
+
let logs;
|
|
38
|
+
const log = (m) => logs.push(m);
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
root = makeRoot();
|
|
41
|
+
out = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-cmd-out-'));
|
|
42
|
+
logs = [];
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
fs_1.default.rmSync(root, { recursive: true, force: true });
|
|
46
|
+
fs_1.default.rmSync(out, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
it('reports exported skills and visible skips', () => {
|
|
49
|
+
(0, export_1.runExportCommand)('dev', { target: 'claude-ai', out }, { roots: [root], zip: okZip, log });
|
|
50
|
+
const text = logs.join('\n');
|
|
51
|
+
expect(text).toMatch(/exported bundle.*dev/i);
|
|
52
|
+
expect(text).toContain('mermaid');
|
|
53
|
+
expect(text).toMatch(/skipped.*proc-skill/i);
|
|
54
|
+
});
|
|
55
|
+
it('prints the manual-zip instruction when the binary is missing', () => {
|
|
56
|
+
const missingZip = () => ({ ok: false, missing: true });
|
|
57
|
+
(0, export_1.runExportCommand)('dev', { target: 'claude-ai', out }, { roots: [root], zip: missingZip, log });
|
|
58
|
+
expect(logs.join('\n')).toMatch(/zip -r/);
|
|
59
|
+
});
|
|
60
|
+
it('propagates unknown-target errors (commander action will exit(1))', () => {
|
|
61
|
+
expect(() => (0, export_1.runExportCommand)('dev', { target: 'nope', out }, { roots: [root], zip: okZip, log }))
|
|
62
|
+
.toThrow(/Valid targets/);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const export_1 = require("../../../src/core/export");
|
|
10
|
+
const okZip = (cwd, zipName) => {
|
|
11
|
+
fs_1.default.writeFileSync(path_1.default.join(cwd, zipName), 'fake-zip');
|
|
12
|
+
return { ok: true, missing: false };
|
|
13
|
+
};
|
|
14
|
+
/** Mismo fixture que resolve.test.ts (root falso con bundle dev + 3 skills). */
|
|
15
|
+
function makeRoot() {
|
|
16
|
+
const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-engine-root-'));
|
|
17
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'bundles/dev'), { recursive: true });
|
|
18
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'catalog.json'), JSON.stringify({
|
|
19
|
+
version: 1,
|
|
20
|
+
bundles: [{ name: 'dev', source: './bundles/dev', version: '1.0.0', scope: 'baseline' }],
|
|
21
|
+
}));
|
|
22
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'bundles/dev/bundle.json'), JSON.stringify({
|
|
23
|
+
name: 'dev', version: '1.0.0', scope: 'baseline', dependsOn: [],
|
|
24
|
+
skills: ['proc-skill', { name: 'mermaid', onSignal: true }, { name: 'ported', onSignal: true }],
|
|
25
|
+
workflows: [], agents: [],
|
|
26
|
+
}));
|
|
27
|
+
const mk = (name, fm) => {
|
|
28
|
+
const dir = path_1.default.join(root, 'skills', name);
|
|
29
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
30
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, 'SKILL.md'), `---\n${fm.join('\n')}\n---\nBody of ${name}.\n`);
|
|
31
|
+
return dir;
|
|
32
|
+
};
|
|
33
|
+
mk('proc-skill', ['name: proc-skill', 'description: "Process skill."']);
|
|
34
|
+
const mermaid = mk('mermaid', ['name: mermaid', 'version: "1.0.0"', 'portable: true', 'description: "Diagrams."']);
|
|
35
|
+
fs_1.default.mkdirSync(path_1.default.join(mermaid, 'references'));
|
|
36
|
+
fs_1.default.writeFileSync(path_1.default.join(mermaid, 'references/flow.md'), 'flow reference bytes');
|
|
37
|
+
const ported = mk('ported', ['name: ported', 'portable: true', 'description: "Ported."']);
|
|
38
|
+
fs_1.default.writeFileSync(path_1.default.join(ported, 'port.claude-ai.md'), '---\nname: ported\ndescription: "Custom port."\n---\nOverride body, verbatim.\n');
|
|
39
|
+
return root;
|
|
40
|
+
}
|
|
41
|
+
describe('runExport (engine end-to-end)', () => {
|
|
42
|
+
let root;
|
|
43
|
+
let out;
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
root = makeRoot();
|
|
46
|
+
out = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-engine-out-'));
|
|
47
|
+
});
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
fs_1.default.rmSync(root, { recursive: true, force: true });
|
|
50
|
+
fs_1.default.rmSync(out, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
it('exports a bundle: transform for mermaid, verbatim override for ported, skips proc-skill', () => {
|
|
53
|
+
const summary = (0, export_1.runExport)({ name: 'dev', out, roots: [root], zip: okZip });
|
|
54
|
+
expect(summary.kind).toBe('bundle');
|
|
55
|
+
expect(summary.exported.map((e) => e.name).sort()).toEqual(['mermaid', 'ported']);
|
|
56
|
+
expect(summary.skipped).toEqual(['proc-skill']);
|
|
57
|
+
const mermaidMd = fs_1.default.readFileSync(path_1.default.join(out, 'claude-ai/mermaid/SKILL.md'), 'utf-8');
|
|
58
|
+
expect(mermaidMd).not.toMatch(/^version:/m);
|
|
59
|
+
expect(mermaidMd).not.toMatch(/^portable:/m);
|
|
60
|
+
expect(mermaidMd).toContain('defer to the registry');
|
|
61
|
+
expect(fs_1.default.readFileSync(path_1.default.join(out, 'claude-ai/mermaid/references/flow.md'), 'utf-8')).toBe('flow reference bytes');
|
|
62
|
+
const portedMd = fs_1.default.readFileSync(path_1.default.join(out, 'claude-ai/ported/SKILL.md'), 'utf-8');
|
|
63
|
+
expect(portedMd).toBe('---\nname: ported\ndescription: "Custom port."\n---\nOverride body, verbatim.\n'); // cero transforms
|
|
64
|
+
});
|
|
65
|
+
it('rejects an unknown target listing the valid ones', () => {
|
|
66
|
+
expect(() => (0, export_1.runExport)({ name: 'dev', target: 'hermes', out, roots: [root], zip: okZip }))
|
|
67
|
+
.toThrow(new RegExp(export_1.EXPORT_TARGETS.join('|')));
|
|
68
|
+
});
|
|
69
|
+
it('wraps transform errors with the offending file path', () => {
|
|
70
|
+
// portable: true se mantiene (si no, resolve.ts corta antes por R2.x y nunca llega
|
|
71
|
+
// a transform.ts); lo que rompe es la ausencia de "description:", que transform.ts exige.
|
|
72
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'skills/mermaid/SKILL.md'), '---\nname: mermaid\nportable: true\n---\nNo description field.\n');
|
|
73
|
+
expect(() => (0, export_1.runExport)({ name: 'mermaid', out, roots: [root], zip: okZip }))
|
|
74
|
+
.toThrow(/mermaid[/\\]SKILL\.md/);
|
|
75
|
+
});
|
|
76
|
+
it('reads only the provided roots (never ~/.claude/skills)', () => {
|
|
77
|
+
// La lectura sale exclusivamente de roots: un root vacío no resuelve nada.
|
|
78
|
+
// Control negativo real: contentRoots() (el fallback al registry instalado)
|
|
79
|
+
// no debe invocarse en absoluto cuando opts.roots viene dado explícitamente.
|
|
80
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
81
|
+
const registries = require('../../../src/core/registries');
|
|
82
|
+
const spy = jest.spyOn(registries, 'contentRoots');
|
|
83
|
+
const empty = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-empty-root-'));
|
|
84
|
+
expect(() => (0, export_1.runExport)({ name: 'mermaid', out, roots: [empty], zip: okZip })).toThrow(/neither a bundle nor a skill/);
|
|
85
|
+
expect(spy).not.toHaveBeenCalled();
|
|
86
|
+
spy.mockRestore();
|
|
87
|
+
fs_1.default.rmSync(empty, { recursive: true, force: true });
|
|
88
|
+
});
|
|
89
|
+
it('defaults --out to ./awm-export/<target> under the current working directory', () => {
|
|
90
|
+
const cwdTmp = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-engine-cwd-'));
|
|
91
|
+
const originalCwd = process.cwd();
|
|
92
|
+
process.chdir(cwdTmp);
|
|
93
|
+
try {
|
|
94
|
+
const summary = (0, export_1.runExport)({ name: 'mermaid', roots: [root], zip: okZip }); // sin `out`
|
|
95
|
+
expect(summary.outDir).toBe(path_1.default.join(cwdTmp, 'awm-export', 'claude-ai'));
|
|
96
|
+
expect(fs_1.default.existsSync(path_1.default.join(cwdTmp, 'awm-export/claude-ai/mermaid/SKILL.md'))).toBe(true);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
process.chdir(originalCwd);
|
|
100
|
+
fs_1.default.rmSync(cwdTmp, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const pack_1 = require("../../../src/core/export/pack");
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
11
|
+
const childProcess = require('child_process');
|
|
12
|
+
const okZip = (cwd, zipName) => {
|
|
13
|
+
fs_1.default.writeFileSync(path_1.default.join(cwd, zipName), 'fake-zip');
|
|
14
|
+
return { ok: true, missing: false };
|
|
15
|
+
};
|
|
16
|
+
const missingZip = () => ({ ok: false, missing: true });
|
|
17
|
+
describe('packSkill', () => {
|
|
18
|
+
let src;
|
|
19
|
+
let out;
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
src = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-pack-src-'));
|
|
22
|
+
out = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-pack-out-'));
|
|
23
|
+
fs_1.default.writeFileSync(path_1.default.join(src, 'SKILL.md'), 'canonical');
|
|
24
|
+
fs_1.default.mkdirSync(path_1.default.join(src, 'references'));
|
|
25
|
+
fs_1.default.writeFileSync(path_1.default.join(src, 'references/a.md'), 'ref-A bytes');
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
fs_1.default.rmSync(src, { recursive: true, force: true });
|
|
29
|
+
fs_1.default.rmSync(out, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
it('writes adapted SKILL.md and byte-identical references', () => {
|
|
32
|
+
const r = (0, pack_1.packSkill)({ name: 'x', adaptedSkillMd: 'adapted', srcDir: src, targetRoot: out, zip: okZip });
|
|
33
|
+
expect(fs_1.default.readFileSync(path_1.default.join(out, 'x/SKILL.md'), 'utf-8')).toBe('adapted');
|
|
34
|
+
expect(fs_1.default.readFileSync(path_1.default.join(out, 'x/references/a.md'), 'utf-8')).toBe('ref-A bytes');
|
|
35
|
+
expect(r.zip).toBe(path_1.default.join(out, 'x.zip'));
|
|
36
|
+
});
|
|
37
|
+
it('re-export cleans its own subtree first (stale files gone)', () => {
|
|
38
|
+
fs_1.default.mkdirSync(path_1.default.join(out, 'x'), { recursive: true });
|
|
39
|
+
fs_1.default.writeFileSync(path_1.default.join(out, 'x/stale.md'), 'old');
|
|
40
|
+
fs_1.default.writeFileSync(path_1.default.join(out, 'x.zip'), 'old-zip');
|
|
41
|
+
(0, pack_1.packSkill)({ name: 'x', adaptedSkillMd: 'adapted', srcDir: src, targetRoot: out, zip: missingZip });
|
|
42
|
+
expect(fs_1.default.existsSync(path_1.default.join(out, 'x/stale.md'))).toBe(false);
|
|
43
|
+
expect(fs_1.default.existsSync(path_1.default.join(out, 'x.zip'))).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
it('falls back to folder-only when zip binary is missing', () => {
|
|
46
|
+
const r = (0, pack_1.packSkill)({ name: 'x', adaptedSkillMd: 'adapted', srcDir: src, targetRoot: out, zip: missingZip });
|
|
47
|
+
expect(r.zip).toBeNull();
|
|
48
|
+
expect(r.zipMissing).toBe(true);
|
|
49
|
+
expect(fs_1.default.existsSync(path_1.default.join(out, 'x/SKILL.md'))).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
it('skill without references/ packs SKILL.md alone', () => {
|
|
52
|
+
fs_1.default.rmSync(path_1.default.join(src, 'references'), { recursive: true });
|
|
53
|
+
(0, pack_1.packSkill)({ name: 'x', adaptedSkillMd: 'adapted', srcDir: src, targetRoot: out, zip: okZip });
|
|
54
|
+
expect(fs_1.default.existsSync(path_1.default.join(out, 'x/references'))).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
it('throws when the zip function reports a real failure (not missing binary)', () => {
|
|
57
|
+
const failingZip = () => ({ ok: false, missing: false });
|
|
58
|
+
expect(() => (0, pack_1.packSkill)({ name: 'x', adaptedSkillMd: 'adapted', srcDir: src, targetRoot: out, zip: failingZip }))
|
|
59
|
+
.toThrow(/zip failed/);
|
|
60
|
+
});
|
|
61
|
+
it('refuses to export when references/ contains a symlink (blocks zip-dereference exfiltration)', () => {
|
|
62
|
+
// Simulate the exfiltration shape: a "secret" file living entirely
|
|
63
|
+
// outside srcDir, symlinked from within references/.
|
|
64
|
+
const secretsDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-pack-secret-'));
|
|
65
|
+
const secretPath = path_1.default.join(secretsDir, 'id_rsa');
|
|
66
|
+
const secretContents = 'THIS-IS-A-PRIVATE-KEY-SHOULD-NEVER-LEAK';
|
|
67
|
+
fs_1.default.writeFileSync(secretPath, secretContents);
|
|
68
|
+
fs_1.default.symlinkSync(secretPath, path_1.default.join(src, 'references/evil-link'));
|
|
69
|
+
try {
|
|
70
|
+
expect(() => (0, pack_1.packSkill)({ name: 'x', adaptedSkillMd: 'adapted', srcDir: src, targetRoot: out, zip: okZip })).toThrow(/symlink/i);
|
|
71
|
+
// Nothing under targetRoot should ever contain the secret's bytes,
|
|
72
|
+
// whether as a copied symlink or a dereferenced regular file.
|
|
73
|
+
const walk = (dir) => {
|
|
74
|
+
if (!fs_1.default.existsSync(dir))
|
|
75
|
+
return [];
|
|
76
|
+
return fs_1.default.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
|
77
|
+
const full = path_1.default.join(dir, e.name);
|
|
78
|
+
if (e.isDirectory())
|
|
79
|
+
return walk(full);
|
|
80
|
+
if (e.isSymbolicLink())
|
|
81
|
+
return [full]; // leaked symlink itself is also unacceptable
|
|
82
|
+
return [full];
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
for (const f of walk(out)) {
|
|
86
|
+
const st = fs_1.default.lstatSync(f);
|
|
87
|
+
if (st.isSymbolicLink()) {
|
|
88
|
+
throw new Error(`leaked symlink found under targetRoot: ${f}`);
|
|
89
|
+
}
|
|
90
|
+
const contents = fs_1.default.readFileSync(f, 'utf-8');
|
|
91
|
+
expect(contents).not.toContain(secretContents);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
fs_1.default.rmSync(secretsDir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
describe('defaultZip (system binary, layered)', () => {
|
|
100
|
+
it('produces a real zip when the binary exists, or reports missing', () => {
|
|
101
|
+
const cwd = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-zip-'));
|
|
102
|
+
fs_1.default.mkdirSync(path_1.default.join(cwd, 'folder'));
|
|
103
|
+
fs_1.default.writeFileSync(path_1.default.join(cwd, 'folder/f.txt'), 'x');
|
|
104
|
+
const r = (0, pack_1.defaultZip)(cwd, 'folder.zip', 'folder');
|
|
105
|
+
if (r.missing) {
|
|
106
|
+
expect(fs_1.default.existsSync(path_1.default.join(cwd, 'folder.zip'))).toBe(false); // degrade limpio
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
expect(r.ok).toBe(true);
|
|
110
|
+
expect(fs_1.default.existsSync(path_1.default.join(cwd, 'folder.zip'))).toBe(true);
|
|
111
|
+
}
|
|
112
|
+
fs_1.default.rmSync(cwd, { recursive: true, force: true });
|
|
113
|
+
});
|
|
114
|
+
it('returns missing:true when spawnSync reports ENOENT (binary absent)', () => {
|
|
115
|
+
const spy = jest.spyOn(childProcess, 'spawnSync').mockReturnValue({
|
|
116
|
+
error: Object.assign(new Error('spawn zip ENOENT'), { code: 'ENOENT' }),
|
|
117
|
+
});
|
|
118
|
+
const r = (0, pack_1.defaultZip)('/irrelevant', 'folder.zip', 'folder');
|
|
119
|
+
expect(r).toEqual({ ok: false, missing: true });
|
|
120
|
+
spy.mockRestore();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const resolve_1 = require("../../../src/core/export/resolve");
|
|
10
|
+
/** Content root falso: catalog + bundle dev + 3 skills. */
|
|
11
|
+
function makeRoot() {
|
|
12
|
+
const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-export-root-'));
|
|
13
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'bundles/dev'), { recursive: true });
|
|
14
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'catalog.json'), JSON.stringify({
|
|
15
|
+
version: 1,
|
|
16
|
+
bundles: [{ name: 'dev', source: './bundles/dev', version: '1.0.0', scope: 'baseline' }],
|
|
17
|
+
}));
|
|
18
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'bundles/dev/bundle.json'), JSON.stringify({
|
|
19
|
+
name: 'dev', version: '1.0.0', scope: 'baseline', dependsOn: [],
|
|
20
|
+
skills: ['proc-skill', { name: 'mermaid', onSignal: true }, { name: 'ported', onSignal: true }],
|
|
21
|
+
workflows: [], agents: [],
|
|
22
|
+
}));
|
|
23
|
+
const mk = (name, fm) => {
|
|
24
|
+
const dir = path_1.default.join(root, 'skills', name);
|
|
25
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
26
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, 'SKILL.md'), `---\n${fm.join('\n')}\n---\nBody.\n`);
|
|
27
|
+
return dir;
|
|
28
|
+
};
|
|
29
|
+
mk('proc-skill', ['name: proc-skill', 'description: "Process skill."']); // NO portable
|
|
30
|
+
mk('mermaid', ['name: mermaid', 'portable: true', 'description: "Diagrams."']);
|
|
31
|
+
const ported = mk('ported', ['name: ported', 'portable: true', 'description: "Ported."']);
|
|
32
|
+
fs_1.default.writeFileSync(path_1.default.join(ported, 'port.claude-ai.md'), '---\nname: ported\ndescription: "Custom."\n---\nCustom body.\n');
|
|
33
|
+
return root;
|
|
34
|
+
}
|
|
35
|
+
describe('resolveExport', () => {
|
|
36
|
+
let root;
|
|
37
|
+
beforeEach(() => { root = makeRoot(); });
|
|
38
|
+
afterEach(() => { fs_1.default.rmSync(root, { recursive: true, force: true }); });
|
|
39
|
+
it('resolves a bundle: portable skills in, non-portable listed as skipped', () => {
|
|
40
|
+
const res = (0, resolve_1.resolveExport)('dev', [root]);
|
|
41
|
+
expect(res.kind).toBe('bundle');
|
|
42
|
+
expect(res.skills.map((s) => s.name).sort()).toEqual(['mermaid', 'ported']);
|
|
43
|
+
expect(res.skipped).toEqual(['proc-skill']);
|
|
44
|
+
});
|
|
45
|
+
it('detects the override path when present', () => {
|
|
46
|
+
const res = (0, resolve_1.resolveExport)('dev', [root]);
|
|
47
|
+
const ported = res.skills.find((s) => s.name === 'ported');
|
|
48
|
+
expect(ported.overridePath).toBe(path_1.default.join(root, 'skills/ported/port.claude-ai.md'));
|
|
49
|
+
const mermaid = res.skills.find((s) => s.name === 'mermaid');
|
|
50
|
+
expect(mermaid.overridePath).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
it('resolves an individual portable skill', () => {
|
|
53
|
+
const res = (0, resolve_1.resolveExport)('mermaid', [root]);
|
|
54
|
+
expect(res.kind).toBe('skill');
|
|
55
|
+
expect(res.skills).toHaveLength(1);
|
|
56
|
+
expect(res.skipped).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
it('fails on an explicitly requested non-portable skill', () => {
|
|
59
|
+
expect(() => (0, resolve_1.resolveExport)('proc-skill', [root])).toThrow(/portable/);
|
|
60
|
+
});
|
|
61
|
+
it('fails on unknown name, listing available bundles', () => {
|
|
62
|
+
expect(() => (0, resolve_1.resolveExport)('nope', [root])).toThrow(/dev/);
|
|
63
|
+
});
|
|
64
|
+
it('fails when a bundle has zero portable skills', () => {
|
|
65
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'skills/mermaid/SKILL.md'), '---\nname: mermaid\ndescription: "D."\n---\nB.\n');
|
|
66
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'skills/ported/SKILL.md'), '---\nname: ported\ndescription: "P."\n---\nB.\n');
|
|
67
|
+
fs_1.default.rmSync(path_1.default.join(root, 'skills/ported/port.claude-ai.md'));
|
|
68
|
+
expect(() => (0, resolve_1.resolveExport)('dev', [root])).toThrow(/no portable skills/i);
|
|
69
|
+
});
|
|
70
|
+
it('fails on override without portable: true (inconsistent metadata)', () => {
|
|
71
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'skills/ported/SKILL.md'), '---\nname: ported\ndescription: "P."\n---\nB.\n');
|
|
72
|
+
expect(() => (0, resolve_1.resolveExport)('ported', [root])).toThrow(/inconsistent/i);
|
|
73
|
+
});
|
|
74
|
+
it('fails on override without portable: true when resolved via a bundle (assertOverrideConsistency bundle-loop path)', () => {
|
|
75
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'skills/ported/SKILL.md'), '---\nname: ported\ndescription: "P."\n---\nB.\n');
|
|
76
|
+
expect(() => (0, resolve_1.resolveExport)('dev', [root])).toThrow(/inconsistent/i);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const transform_1 = require("../../../src/core/export/transform");
|
|
4
|
+
const FM = (lines) => `---\n${lines.join('\n')}\n---\nBody line.\n`;
|
|
5
|
+
describe('claudeAiTransform', () => {
|
|
6
|
+
it('strips version and portable, keeps other keys and body intact', () => {
|
|
7
|
+
const input = FM(['name: mermaid-diagrams', 'version: "1.0.0"', 'portable: true', 'description: "Guide."']);
|
|
8
|
+
const out = (0, transform_1.claudeAiTransform)(input, 'mermaid-diagrams');
|
|
9
|
+
expect(out).not.toMatch(/^version:/m);
|
|
10
|
+
expect(out).not.toMatch(/^portable:/m);
|
|
11
|
+
expect(out).toMatch(/^name: mermaid-diagrams$/m);
|
|
12
|
+
expect(out).toContain('Body line.\n');
|
|
13
|
+
});
|
|
14
|
+
it('appends the deference line inside a quoted description', () => {
|
|
15
|
+
const input = FM(['name: x', 'portable: true', 'description: "Does things."']);
|
|
16
|
+
const out = (0, transform_1.claudeAiTransform)(input, 'x');
|
|
17
|
+
expect(out).toContain(`description: "Does things. ${(0, transform_1.DEFERENCE_LINE)('x')}"`);
|
|
18
|
+
});
|
|
19
|
+
it('appends the deference line to an unquoted description', () => {
|
|
20
|
+
const input = FM(['name: x', 'portable: true', 'description: Does things.']);
|
|
21
|
+
const out = (0, transform_1.claudeAiTransform)(input, 'x');
|
|
22
|
+
expect(out).toContain(`description: Does things. ${(0, transform_1.DEFERENCE_LINE)('x')}`);
|
|
23
|
+
});
|
|
24
|
+
it('appends the deference line inside a single-quoted description', () => {
|
|
25
|
+
const input = FM(['name: x', 'portable: true', "description: 'Does things.'"]);
|
|
26
|
+
const out = (0, transform_1.claudeAiTransform)(input, 'x');
|
|
27
|
+
// DEFERENCE_LINE itself always contains an apostrophe ("registry's"), so
|
|
28
|
+
// even a fixture with no apostrophe of its own must see it doubled ('')
|
|
29
|
+
// per YAML single-quote escaping once spliced into a single-quoted scalar.
|
|
30
|
+
expect(out).toContain(`description: 'Does things. ${(0, transform_1.DEFERENCE_LINE)('x').replace(/'/g, "''")}'`);
|
|
31
|
+
});
|
|
32
|
+
it('appends the deference line inside a double-quoted description with trailing whitespace', () => {
|
|
33
|
+
const input = FM(['name: x', 'portable: true', 'description: "Does things." ']);
|
|
34
|
+
const out = (0, transform_1.claudeAiTransform)(input, 'x');
|
|
35
|
+
expect(out).toContain(`description: "Does things. ${(0, transform_1.DEFERENCE_LINE)('x')}"`);
|
|
36
|
+
});
|
|
37
|
+
it('accepts CRLF-terminated frontmatter', () => {
|
|
38
|
+
const input = '---\r\nname: x\r\nportable: true\r\ndescription: "Does things."\r\n---\r\nBody line.\r\n';
|
|
39
|
+
const out = (0, transform_1.claudeAiTransform)(input, 'x');
|
|
40
|
+
expect(out).not.toMatch(/^portable:/m);
|
|
41
|
+
expect(out).toContain(`description: "Does things. ${(0, transform_1.DEFERENCE_LINE)('x')}"`);
|
|
42
|
+
expect(out).toContain('Body line.\r\n');
|
|
43
|
+
});
|
|
44
|
+
it('throws on missing frontmatter block', () => {
|
|
45
|
+
expect(() => (0, transform_1.claudeAiTransform)('No frontmatter here.', 'x')).toThrow(/frontmatter/);
|
|
46
|
+
});
|
|
47
|
+
it('throws on unterminated frontmatter block', () => {
|
|
48
|
+
expect(() => (0, transform_1.claudeAiTransform)('---\nname: x\ndescription: "D."\n', 'x')).toThrow(/unterminated/);
|
|
49
|
+
});
|
|
50
|
+
it('throws on frontmatter without description', () => {
|
|
51
|
+
expect(() => (0, transform_1.claudeAiTransform)(FM(['name: x', 'portable: true']), 'x')).toThrow(/description/);
|
|
52
|
+
});
|
|
53
|
+
it('throws on multi-line (block scalar) description', () => {
|
|
54
|
+
expect(() => (0, transform_1.claudeAiTransform)(FM(['name: x', 'description: >', ' folded text']), 'x')).toThrow(/single-line/);
|
|
55
|
+
});
|
|
56
|
+
it('escapes an apostrophe in the deference line when appending to a single-quoted description', () => {
|
|
57
|
+
const input = FM(['name: mermaid', 'portable: true', "description: 'Diagrams and flowcharts.'"]);
|
|
58
|
+
const out = (0, transform_1.claudeAiTransform)(input, 'mermaid');
|
|
59
|
+
// The apostrophe in "registry's" must be doubled ('') per YAML single-quote escaping.
|
|
60
|
+
expect(out).toContain("registry''s mermaid skill");
|
|
61
|
+
expect(out).not.toContain("registry's mermaid skill");
|
|
62
|
+
const descLine = out.split('\n').find((l) => l.startsWith('description:'));
|
|
63
|
+
expect(descLine).toBe(`description: 'Diagrams and flowcharts. ${(0, transform_1.DEFERENCE_LINE)('mermaid').replace(/'/g, "''")}'`);
|
|
64
|
+
// Sanity check the result is well-formed: single-quoted scalar body has no
|
|
65
|
+
// lone (unescaped) apostrophes — every ' is either the opening/closing
|
|
66
|
+
// quote or part of a doubled '' pair.
|
|
67
|
+
const body = descLine.slice('description: \''.length, -1);
|
|
68
|
+
expect(body.replace(/''/g, '')).not.toMatch(/'/);
|
|
69
|
+
});
|
|
70
|
+
it('throws when a quoted description has trailing content after its closing quote (e.g. inline comment)', () => {
|
|
71
|
+
const input = FM(['name: x', 'portable: true', 'description: "Does things." # a comment']);
|
|
72
|
+
expect(() => (0, transform_1.claudeAiTransform)(input, 'x')).toThrow(/trailing content|comment/i);
|
|
73
|
+
});
|
|
74
|
+
});
|