mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
feat(skillpack): scaffold subcommand + SKILL.md frontmatter sources (T2)
New scaffold.ts replaces the managed-block installer. One-time additive copy into the user's repo via copyArtifacts; refuses to overwrite existing files (user owns them). Partial-state policy: copies missing paired sources even when the skill dir already exists. bundle.ts extended with loadSkillSources + enumerateScaffoldEntries — paired source files declared in each SKILL.md's frontmatter sources: array, not in openclaw.plugin.json. Single source of truth, co-located with the skill. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
ea3559239a
commit
01ab21c407
@@ -10,6 +10,8 @@
|
||||
import { existsSync, readFileSync, statSync, readdirSync } from 'fs';
|
||||
import { join, dirname, isAbsolute, resolve } from 'path';
|
||||
|
||||
import { parseMarkdown } from '../markdown.ts';
|
||||
|
||||
export interface BundleManifest {
|
||||
name: string;
|
||||
version: string;
|
||||
@@ -224,3 +226,240 @@ export function pathSlug(relPath: string): string {
|
||||
export function bundledSkillSlugs(manifest: BundleManifest): string[] {
|
||||
return manifest.skills.map(pathSlug).sort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frontmatter `sources:` — paired source files declared by a skill (v0.33+)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A skill that ships paired implementation (e.g. book-mirror's
|
||||
// `src/commands/book-mirror.ts`) declares it in its SKILL.md frontmatter:
|
||||
//
|
||||
// ---
|
||||
// name: book-mirror
|
||||
// sources:
|
||||
// - src/commands/book-mirror.ts
|
||||
// ---
|
||||
//
|
||||
// The bundler reads this on every enumerate; scaffold copies the paired
|
||||
// files alongside the skill markdown. Single source of truth co-located
|
||||
// with the skill — no parallel manifest in openclaw.plugin.json.
|
||||
|
||||
/** A skill's declared paired-source paths (repo-relative). */
|
||||
export interface SkillSources {
|
||||
slug: string;
|
||||
sources: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `<gbrainRoot>/<skillRel>/SKILL.md` and return its `sources:`
|
||||
* frontmatter array. Empty array when absent or empty.
|
||||
*
|
||||
* Fail-loud validation (throws `BundleError` with `manifest_malformed`):
|
||||
* - every entry must be a string
|
||||
* - relative path only (no leading `/`, no `../` traversal)
|
||||
* - every referenced file must exist under `<gbrainRoot>`
|
||||
*
|
||||
* Skills without `sources:` declared, or with `sources: []`, return an
|
||||
* empty array (no validation work performed).
|
||||
*/
|
||||
export function loadSkillSources(gbrainRoot: string, skillRel: string): SkillSources {
|
||||
const slug = pathSlug(skillRel);
|
||||
const skillMd = join(gbrainRoot, skillRel, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) {
|
||||
// Some bundled "skills" are markdown-only without a SKILL.md (rare,
|
||||
// e.g. shared-conventions directories). Treat as no sources.
|
||||
return { slug, sources: [] };
|
||||
}
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(skillMd, 'utf-8');
|
||||
} catch (err) {
|
||||
throw new BundleError(
|
||||
`Failed to read ${skillMd}: ${(err as Error).message}`,
|
||||
'manifest_malformed',
|
||||
);
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseMarkdown(content, skillMd);
|
||||
} catch (err) {
|
||||
throw new BundleError(
|
||||
`${skillMd}: frontmatter parse error — ${(err as Error).message}`,
|
||||
'manifest_malformed',
|
||||
);
|
||||
}
|
||||
const raw = parsed.frontmatter.sources;
|
||||
if (raw === undefined || raw === null) {
|
||||
return { slug, sources: [] };
|
||||
}
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new BundleError(
|
||||
`${skillMd}: frontmatter \`sources:\` must be an array of strings`,
|
||||
'manifest_malformed',
|
||||
);
|
||||
}
|
||||
const sources: string[] = [];
|
||||
for (const entry of raw) {
|
||||
if (typeof entry !== 'string') {
|
||||
throw new BundleError(
|
||||
`${skillMd}: every entry in frontmatter \`sources:\` must be a string`,
|
||||
'manifest_malformed',
|
||||
);
|
||||
}
|
||||
if (entry.length === 0) {
|
||||
throw new BundleError(
|
||||
`${skillMd}: empty string in frontmatter \`sources:\``,
|
||||
'manifest_malformed',
|
||||
);
|
||||
}
|
||||
if (isAbsolute(entry)) {
|
||||
throw new BundleError(
|
||||
`${skillMd}: frontmatter \`sources:\` entry "${entry}" must be relative to the repo root, not absolute`,
|
||||
'manifest_malformed',
|
||||
);
|
||||
}
|
||||
if (entry.includes('..')) {
|
||||
throw new BundleError(
|
||||
`${skillMd}: frontmatter \`sources:\` entry "${entry}" contains \`..\` traversal — refusing for safety`,
|
||||
'manifest_malformed',
|
||||
);
|
||||
}
|
||||
const abs = join(gbrainRoot, entry);
|
||||
if (!existsSync(abs)) {
|
||||
throw new BundleError(
|
||||
`${skillMd}: frontmatter \`sources:\` declares "${entry}" but the file is missing from ${gbrainRoot}`,
|
||||
'manifest_malformed',
|
||||
);
|
||||
}
|
||||
sources.push(entry);
|
||||
}
|
||||
return { slug, sources };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ScaffoldEntry — workspace-rooted, includes paired-source files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Like `BundleEntry`, but `relWorkspaceTarget` is rooted at the target
|
||||
* workspace (not the target skills dir). Lets scaffold place paired
|
||||
* source files at their mirror path (`src/commands/foo.ts`) alongside
|
||||
* the skill markdown (`skills/<slug>/SKILL.md`).
|
||||
*/
|
||||
export interface ScaffoldEntry {
|
||||
source: string;
|
||||
relWorkspaceTarget: string;
|
||||
sharedDep: boolean;
|
||||
/** Whether from a skill's frontmatter `sources:` declaration. */
|
||||
pairedSource: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate every file the new scaffold model would copy. Workspace-
|
||||
* rooted targets:
|
||||
* - skill files → `skills/<slug>/<rel>`
|
||||
* - shared deps → `skills/<rel>`
|
||||
* - paired sources → `<source>` (e.g. `src/commands/book-mirror.ts`)
|
||||
*
|
||||
* Fail-loud on missing declared paired sources via `loadSkillSources`.
|
||||
*/
|
||||
export function enumerateScaffoldEntries(opts: EnumerateOptions): ScaffoldEntry[] {
|
||||
const { gbrainRoot, skillSlug, manifest } = opts;
|
||||
const entries: ScaffoldEntry[] = [];
|
||||
|
||||
const skillsToIncludePaths = skillSlug
|
||||
? manifest.skills.filter(p => pathSlug(p) === skillSlug)
|
||||
: manifest.skills;
|
||||
|
||||
if (skillSlug && skillsToIncludePaths.length === 0) {
|
||||
throw new BundleError(
|
||||
`Skill '${skillSlug}' is not listed in openclaw.plugin.json#skills`,
|
||||
'skill_not_found',
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Skill files — every file under `<gbrainRoot>/skills/<slug>/`.
|
||||
// relWorkspaceTarget = `skills/<slug>/<rest>` (workspace-rooted).
|
||||
for (const rel of skillsToIncludePaths) {
|
||||
const abs = join(gbrainRoot, rel);
|
||||
if (!existsSync(abs)) {
|
||||
throw new BundleError(
|
||||
`Bundle lists '${rel}' but the path does not exist in ${gbrainRoot}`,
|
||||
'skill_not_found',
|
||||
);
|
||||
}
|
||||
walkScaffoldFiles(abs, rel, entries, false, false);
|
||||
}
|
||||
|
||||
// 2. Paired sources — declared via each skill's frontmatter `sources:`.
|
||||
// relWorkspaceTarget = `<source>` (already workspace-relative).
|
||||
for (const rel of skillsToIncludePaths) {
|
||||
const { sources } = loadSkillSources(gbrainRoot, rel);
|
||||
for (const src of sources) {
|
||||
entries.push({
|
||||
source: join(gbrainRoot, src),
|
||||
relWorkspaceTarget: src,
|
||||
sharedDep: false,
|
||||
pairedSource: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Shared deps — convention files etc. relWorkspaceTarget = `skills/<rest>`.
|
||||
for (const dep of manifest.shared_deps) {
|
||||
const abs = join(gbrainRoot, dep);
|
||||
if (!existsSync(abs)) continue; // missing shared dep is a warning, not fatal
|
||||
let stat;
|
||||
try {
|
||||
stat = statSync(abs);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
walkScaffoldFiles(abs, dep, entries, true, false);
|
||||
} else if (stat.isFile()) {
|
||||
entries.push({
|
||||
source: abs,
|
||||
relWorkspaceTarget: dep,
|
||||
sharedDep: true,
|
||||
pairedSource: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function walkScaffoldFiles(
|
||||
absDir: string,
|
||||
workspaceRelPrefix: string,
|
||||
out: ScaffoldEntry[],
|
||||
sharedDep: boolean,
|
||||
pairedSource: boolean,
|
||||
): void {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(absDir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const abs = join(absDir, e);
|
||||
let stat;
|
||||
try {
|
||||
stat = statSync(abs);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
walkScaffoldFiles(abs, join(workspaceRelPrefix, e), out, sharedDep, pairedSource);
|
||||
} else if (stat.isFile()) {
|
||||
out.push({
|
||||
source: abs,
|
||||
relWorkspaceTarget: join(workspaceRelPrefix, e),
|
||||
sharedDep,
|
||||
pairedSource,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* skillpack/scaffold.ts — `gbrain skillpack scaffold <name>`.
|
||||
*
|
||||
* One-time, additive copy of a bundled skill into a host workspace. The
|
||||
* file-copy primitive is shared with harvest (host→gbrain) via
|
||||
* `copyArtifacts`. The bundle enumeration is shared with the bundle
|
||||
* manifest itself via `enumerateScaffoldEntries` — which now also picks
|
||||
* up paired source files declared in each skill's frontmatter
|
||||
* `sources:` array.
|
||||
*
|
||||
* Contracts (the new model):
|
||||
* 1. **No managed-block writes.** The host's RESOLVER.md / AGENTS.md
|
||||
* stays untouched. Routing happens via each skill's frontmatter
|
||||
* `triggers:` array, which downstream agents walk at runtime.
|
||||
* 2. **Refuses to overwrite existing files.** Once a file lands, the
|
||||
* user owns it. To update, run `gbrain skillpack reference <name>`
|
||||
* and decide.
|
||||
* 3. **Partial-state policy.** When `skills/<slug>/` already exists
|
||||
* but the skill's frontmatter declares paired `sources:` that are
|
||||
* missing on host, scaffold copies the missing paired files into
|
||||
* place. Existing files are still preserved. Closes the
|
||||
* "skill shipped, later gained a paired source" gap.
|
||||
* 4. **No lockfile, no cumulative-slugs receipt, no `--all` prune.**
|
||||
* All deleted. The new model lets the user own the files; nothing
|
||||
* to lock or to track.
|
||||
*/
|
||||
|
||||
import { join } from 'path';
|
||||
|
||||
import { copyArtifacts, walkSourceDir } from './copy.ts';
|
||||
import type { CopyItem } from './copy.ts';
|
||||
import { enumerateScaffoldEntries, loadBundleManifest } from './bundle.ts';
|
||||
import type { ScaffoldEntry } from './bundle.ts';
|
||||
|
||||
export interface ScaffoldOptions {
|
||||
/** Absolute path to gbrain repo root (source-of-truth bundle). */
|
||||
gbrainRoot: string;
|
||||
/** Absolute path to the target workspace (e.g. ~/git/wintermute). */
|
||||
targetWorkspace: string;
|
||||
/** Single skill slug, or `null` for --all. */
|
||||
skillSlug: string | null;
|
||||
/** Dry-run: validate + report; no writes. */
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export type ScaffoldOutcome = 'wrote_new' | 'skipped_existing';
|
||||
|
||||
export interface ScaffoldFileResult {
|
||||
source: string;
|
||||
target: string;
|
||||
outcome: ScaffoldOutcome;
|
||||
sharedDep: boolean;
|
||||
pairedSource: boolean;
|
||||
}
|
||||
|
||||
export interface ScaffoldResult {
|
||||
dryRun: boolean;
|
||||
files: ScaffoldFileResult[];
|
||||
summary: {
|
||||
wroteNew: number;
|
||||
skippedExisting: number;
|
||||
/** Among `wroteNew`, how many were paired source files (frontmatter
|
||||
* `sources:`) — useful for partial-state cases where the skill
|
||||
* already existed but a paired source was missing. */
|
||||
pairedSourcesWritten: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class ScaffoldError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: 'bundle_error' | 'target_missing' | 'unknown_skill',
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ScaffoldError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a scaffold. Loads the bundle manifest, enumerates every file the
|
||||
* skill (or all skills when slug=null) would land, and copies them into
|
||||
* the target workspace at their mirror paths. Refuses to overwrite any
|
||||
* existing file.
|
||||
*
|
||||
* Idempotent: re-running on a fully-scaffolded workspace is a no-op.
|
||||
*
|
||||
* Partial-state handled naturally: if `skills/<slug>/` exists but a
|
||||
* declared paired source is missing, the missing item is copied while
|
||||
* the present ones are skipped.
|
||||
*/
|
||||
export function runScaffold(opts: ScaffoldOptions): ScaffoldResult {
|
||||
const manifest = loadBundleManifest(opts.gbrainRoot);
|
||||
|
||||
// enumerateScaffoldEntries throws BundleError if the slug is unknown
|
||||
// or if any declared paired source is missing on disk. Surface those
|
||||
// as ScaffoldError with the matching code for caller ergonomics.
|
||||
let entries: ScaffoldEntry[];
|
||||
try {
|
||||
entries = enumerateScaffoldEntries({
|
||||
gbrainRoot: opts.gbrainRoot,
|
||||
skillSlug: opts.skillSlug ?? undefined,
|
||||
manifest,
|
||||
});
|
||||
} catch (err) {
|
||||
const e = err as Error & { code?: string };
|
||||
if (e.code === 'skill_not_found') {
|
||||
throw new ScaffoldError(e.message, 'unknown_skill');
|
||||
}
|
||||
throw new ScaffoldError(e.message, 'bundle_error');
|
||||
}
|
||||
|
||||
// Map ScaffoldEntry → CopyItem (workspace-rooted target path).
|
||||
const items: CopyItem[] = entries.map(e => ({
|
||||
source: e.source,
|
||||
target: join(opts.targetWorkspace, e.relWorkspaceTarget),
|
||||
}));
|
||||
|
||||
// Shared copy primitive. Refuses to overwrite existing files; no
|
||||
// symlink check or path confinement (the scaffold source is gbrain's
|
||||
// own trusted bundle).
|
||||
const copyResult = copyArtifacts(items, { dryRun: opts.dryRun });
|
||||
|
||||
// Stitch outcomes back to ScaffoldEntry metadata so callers can tell
|
||||
// sharedDep / pairedSource per file.
|
||||
const files: ScaffoldFileResult[] = copyResult.files.map((f, i) => ({
|
||||
source: f.source,
|
||||
target: f.target,
|
||||
outcome: f.outcome,
|
||||
sharedDep: entries[i].sharedDep,
|
||||
pairedSource: entries[i].pairedSource,
|
||||
}));
|
||||
|
||||
return {
|
||||
dryRun: copyResult.dryRun,
|
||||
files,
|
||||
summary: {
|
||||
wroteNew: copyResult.summary.wroteNew,
|
||||
skippedExisting: copyResult.summary.skippedExisting,
|
||||
pairedSourcesWritten: files.filter(
|
||||
f => f.outcome === 'wrote_new' && f.pairedSource,
|
||||
).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export the public symbols from copy.ts so callers can `import
|
||||
// { walkSourceDir } from 'scaffold.ts'` if they prefer one entry point.
|
||||
// (Not load-bearing — direct imports from copy.ts are equally fine.)
|
||||
export { copyArtifacts, walkSourceDir };
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Tests for `loadSkillSources` in src/core/skillpack/bundle.ts —
|
||||
* the per-skill paired-source declaration via SKILL.md frontmatter
|
||||
* `sources:` array (v0.33+, D2).
|
||||
*
|
||||
* Pins the fail-loud validation contract:
|
||||
* - empty/absent `sources:` → empty array, no error
|
||||
* - non-string entry → BundleError(manifest_malformed)
|
||||
* - absolute path → BundleError
|
||||
* - `..` traversal → BundleError
|
||||
* - declared file missing on disk → BundleError
|
||||
*/
|
||||
|
||||
import { describe, expect, it, afterEach } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
import { BundleError, loadSkillSources } from '../src/core/skillpack/bundle.ts';
|
||||
|
||||
const created: string[] = [];
|
||||
afterEach(() => {
|
||||
while (created.length) {
|
||||
const p = created.pop()!;
|
||||
try {
|
||||
rmSync(p, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
function scratchGbrain(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'fms-gbrain-'));
|
||||
created.push(root);
|
||||
mkdirSync(join(root, 'src', 'commands'), { recursive: true });
|
||||
mkdirSync(join(root, 'skills', 'sample'), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
function writeSkill(root: string, name: string, frontmatter: string): void {
|
||||
const dir = join(root, 'skills', name);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'SKILL.md'), `---\n${frontmatter}\n---\n# ${name}\n`);
|
||||
}
|
||||
|
||||
describe('loadSkillSources', () => {
|
||||
it('returns empty array when frontmatter has no `sources:` field', () => {
|
||||
const root = scratchGbrain();
|
||||
writeSkill(root, 'plain', 'name: plain\nversion: 0.1.0');
|
||||
|
||||
const out = loadSkillSources(root, 'skills/plain');
|
||||
expect(out.slug).toBe('plain');
|
||||
expect(out.sources).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when SKILL.md is missing (shared-conventions dirs)', () => {
|
||||
const root = scratchGbrain();
|
||||
mkdirSync(join(root, 'skills', 'no-md-dir'));
|
||||
writeFileSync(join(root, 'skills', 'no-md-dir', 'README.md'), 'no skill here');
|
||||
|
||||
const out = loadSkillSources(root, 'skills/no-md-dir');
|
||||
expect(out.sources).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads a valid `sources:` array and returns repo-relative paths', () => {
|
||||
const root = scratchGbrain();
|
||||
writeFileSync(join(root, 'src', 'commands', 'demo.ts'), '// stub');
|
||||
writeSkill(
|
||||
root,
|
||||
'demo',
|
||||
'name: demo\nsources:\n - src/commands/demo.ts',
|
||||
);
|
||||
|
||||
const out = loadSkillSources(root, 'skills/demo');
|
||||
expect(out.sources).toEqual(['src/commands/demo.ts']);
|
||||
});
|
||||
|
||||
it('returns empty array when `sources: []` is explicitly empty', () => {
|
||||
const root = scratchGbrain();
|
||||
writeSkill(root, 'empty-sources', 'name: empty-sources\nsources: []');
|
||||
|
||||
const out = loadSkillSources(root, 'skills/empty-sources');
|
||||
expect(out.sources).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws BundleError when `sources:` is not an array', () => {
|
||||
const root = scratchGbrain();
|
||||
writeSkill(root, 'bad', 'name: bad\nsources: not-an-array');
|
||||
|
||||
expect(() => loadSkillSources(root, 'skills/bad')).toThrow(BundleError);
|
||||
});
|
||||
|
||||
it('throws when an entry is not a string', () => {
|
||||
const root = scratchGbrain();
|
||||
writeSkill(root, 'bad', 'name: bad\nsources:\n - 42');
|
||||
|
||||
expect(() => loadSkillSources(root, 'skills/bad')).toThrow(BundleError);
|
||||
});
|
||||
|
||||
it('throws on absolute paths', () => {
|
||||
const root = scratchGbrain();
|
||||
writeSkill(root, 'bad', 'name: bad\nsources:\n - /etc/passwd');
|
||||
|
||||
expect(() => loadSkillSources(root, 'skills/bad')).toThrow(/absolute/);
|
||||
});
|
||||
|
||||
it('throws on `..` traversal', () => {
|
||||
const root = scratchGbrain();
|
||||
writeSkill(root, 'bad', 'name: bad\nsources:\n - ../other-repo/src/leak.ts');
|
||||
|
||||
expect(() => loadSkillSources(root, 'skills/bad')).toThrow(/traversal/);
|
||||
});
|
||||
|
||||
it('throws when a declared source file is missing on disk', () => {
|
||||
const root = scratchGbrain();
|
||||
writeSkill(
|
||||
root,
|
||||
'gone',
|
||||
'name: gone\nsources:\n - src/commands/never-built.ts',
|
||||
);
|
||||
|
||||
expect(() => loadSkillSources(root, 'skills/gone')).toThrow(/missing from/);
|
||||
});
|
||||
|
||||
it('throws on empty string entries', () => {
|
||||
const root = scratchGbrain();
|
||||
writeSkill(root, 'bad', 'name: bad\nsources:\n - ""');
|
||||
|
||||
expect(() => loadSkillSources(root, 'skills/bad')).toThrow(BundleError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Tests for src/core/skillpack/scaffold.ts — the new (v0.33) scaffold
|
||||
* model that replaces the managed-block installer.
|
||||
*
|
||||
* Pins:
|
||||
* - happy path: skill files + shared deps + paired sources land at
|
||||
* workspace-rooted paths
|
||||
* - refuses to overwrite existing files (the user owns them)
|
||||
* - partial-state policy: skill present, paired source missing → fill
|
||||
* - --all (skillSlug: null) installs every bundled skill
|
||||
* - dry-run reports outcomes but writes nothing
|
||||
* - IRON-RULE regressions: no managed block, no lockfile, no
|
||||
* cumulative-slugs receipt, no .gbrain-skillpack.lock file
|
||||
*/
|
||||
|
||||
import { describe, expect, it, afterEach } from 'bun:test';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
import { runScaffold, ScaffoldError } from '../src/core/skillpack/scaffold.ts';
|
||||
|
||||
const created: string[] = [];
|
||||
afterEach(() => {
|
||||
while (created.length) {
|
||||
const p = created.pop()!;
|
||||
try {
|
||||
rmSync(p, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
interface GbrainFixture {
|
||||
gbrainRoot: string;
|
||||
}
|
||||
|
||||
function scratchGbrain(opts: { withPairedSource?: boolean } = {}): GbrainFixture {
|
||||
const root = mkdtempSync(join(tmpdir(), 'sp-scaffold-gbrain-'));
|
||||
created.push(root);
|
||||
mkdirSync(join(root, 'src', 'cli.ts').replace('cli.ts', ''), { recursive: true });
|
||||
writeFileSync(join(root, 'src', 'cli.ts'), '// stub');
|
||||
mkdirSync(join(root, 'src', 'commands'), { recursive: true });
|
||||
|
||||
// book-mirror with paired source
|
||||
mkdirSync(join(root, 'skills', 'book-mirror'), { recursive: true });
|
||||
const bmFm = opts.withPairedSource
|
||||
? '---\nname: book-mirror\ntriggers:\n - bm trigger\nsources:\n - src/commands/book-mirror.ts\n---\n# book-mirror\n'
|
||||
: '---\nname: book-mirror\ntriggers:\n - bm trigger\n---\n# book-mirror\n';
|
||||
writeFileSync(join(root, 'skills', 'book-mirror', 'SKILL.md'), bmFm);
|
||||
writeFileSync(join(root, 'skills', 'book-mirror', 'routing-eval.jsonl'), '{"intent":"bm"}\n');
|
||||
|
||||
if (opts.withPairedSource) {
|
||||
writeFileSync(join(root, 'src', 'commands', 'book-mirror.ts'), '// real impl\n');
|
||||
}
|
||||
|
||||
// plain second skill (no paired source)
|
||||
mkdirSync(join(root, 'skills', 'query'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, 'skills', 'query', 'SKILL.md'),
|
||||
'---\nname: query\ntriggers:\n - q trigger\n---\n# query\n',
|
||||
);
|
||||
|
||||
// shared deps
|
||||
mkdirSync(join(root, 'skills', 'conventions'), { recursive: true });
|
||||
writeFileSync(join(root, 'skills', 'conventions', 'quality.md'), '# quality\n');
|
||||
writeFileSync(join(root, 'skills', '_brain-filing-rules.md'), '# filing rules\n');
|
||||
|
||||
// bundle manifest
|
||||
writeFileSync(
|
||||
join(root, 'openclaw.plugin.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'gbrain-test',
|
||||
version: '0.33.0-test',
|
||||
skills: ['skills/book-mirror', 'skills/query'],
|
||||
shared_deps: ['skills/conventions', 'skills/_brain-filing-rules.md'],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return { gbrainRoot: root };
|
||||
}
|
||||
|
||||
function scratchWorkspace(): string {
|
||||
const ws = mkdtempSync(join(tmpdir(), 'sp-scaffold-ws-'));
|
||||
created.push(ws);
|
||||
return ws;
|
||||
}
|
||||
|
||||
describe('runScaffold — happy path', () => {
|
||||
it('copies a single skill plus shared deps to the workspace', () => {
|
||||
const { gbrainRoot } = scratchGbrain();
|
||||
const ws = scratchWorkspace();
|
||||
|
||||
const result = runScaffold({
|
||||
gbrainRoot,
|
||||
targetWorkspace: ws,
|
||||
skillSlug: 'book-mirror',
|
||||
});
|
||||
|
||||
expect(result.summary.wroteNew).toBeGreaterThan(0);
|
||||
expect(existsSync(join(ws, 'skills', 'book-mirror', 'SKILL.md'))).toBe(true);
|
||||
expect(existsSync(join(ws, 'skills', 'book-mirror', 'routing-eval.jsonl'))).toBe(true);
|
||||
expect(existsSync(join(ws, 'skills', 'conventions', 'quality.md'))).toBe(true);
|
||||
expect(existsSync(join(ws, 'skills', '_brain-filing-rules.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('copies paired source files declared in frontmatter `sources:`', () => {
|
||||
const { gbrainRoot } = scratchGbrain({ withPairedSource: true });
|
||||
const ws = scratchWorkspace();
|
||||
|
||||
const result = runScaffold({
|
||||
gbrainRoot,
|
||||
targetWorkspace: ws,
|
||||
skillSlug: 'book-mirror',
|
||||
});
|
||||
|
||||
expect(existsSync(join(ws, 'src', 'commands', 'book-mirror.ts'))).toBe(true);
|
||||
expect(readFileSync(join(ws, 'src', 'commands', 'book-mirror.ts'), 'utf-8')).toBe(
|
||||
'// real impl\n',
|
||||
);
|
||||
expect(result.summary.pairedSourcesWritten).toBe(1);
|
||||
});
|
||||
|
||||
it('--all (skillSlug: null) installs every bundled skill', () => {
|
||||
const { gbrainRoot } = scratchGbrain();
|
||||
const ws = scratchWorkspace();
|
||||
|
||||
runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: null });
|
||||
|
||||
expect(existsSync(join(ws, 'skills', 'book-mirror', 'SKILL.md'))).toBe(true);
|
||||
expect(existsSync(join(ws, 'skills', 'query', 'SKILL.md'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runScaffold — refuses to overwrite (user owns the files)', () => {
|
||||
it('re-running is idempotent (every file skipped_existing)', () => {
|
||||
const { gbrainRoot } = scratchGbrain();
|
||||
const ws = scratchWorkspace();
|
||||
|
||||
runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'book-mirror' });
|
||||
const second = runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'book-mirror' });
|
||||
|
||||
expect(second.summary.wroteNew).toBe(0);
|
||||
expect(second.summary.skippedExisting).toBeGreaterThan(0);
|
||||
expect(second.files.every(f => f.outcome === 'skipped_existing')).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves local edits to a scaffolded file', () => {
|
||||
const { gbrainRoot } = scratchGbrain();
|
||||
const ws = scratchWorkspace();
|
||||
|
||||
runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'book-mirror' });
|
||||
writeFileSync(join(ws, 'skills', 'book-mirror', 'SKILL.md'), 'MY EDITS');
|
||||
|
||||
runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'book-mirror' });
|
||||
expect(readFileSync(join(ws, 'skills', 'book-mirror', 'SKILL.md'), 'utf-8')).toBe('MY EDITS');
|
||||
});
|
||||
|
||||
it('does not overwrite existing shared-dep files', () => {
|
||||
const { gbrainRoot } = scratchGbrain();
|
||||
const ws = scratchWorkspace();
|
||||
|
||||
mkdirSync(join(ws, 'skills', 'conventions'), { recursive: true });
|
||||
writeFileSync(join(ws, 'skills', 'conventions', 'quality.md'), 'USER OWNS THIS');
|
||||
|
||||
runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'book-mirror' });
|
||||
expect(readFileSync(join(ws, 'skills', 'conventions', 'quality.md'), 'utf-8')).toBe(
|
||||
'USER OWNS THIS',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runScaffold — partial-state policy (F-CDX-6)', () => {
|
||||
it('skill dir exists but paired source missing → copies the paired source only', () => {
|
||||
const { gbrainRoot } = scratchGbrain({ withPairedSource: true });
|
||||
const ws = scratchWorkspace();
|
||||
|
||||
// First, scaffold without the paired source declared (simulating "skill
|
||||
// shipped before sources: was added"). We model this by pre-creating
|
||||
// the skill files manually:
|
||||
mkdirSync(join(ws, 'skills', 'book-mirror'), { recursive: true });
|
||||
writeFileSync(join(ws, 'skills', 'book-mirror', 'SKILL.md'), '# pre-existing skill content\n');
|
||||
writeFileSync(join(ws, 'skills', 'book-mirror', 'routing-eval.jsonl'), '{}\n');
|
||||
|
||||
// Now scaffold (gbrain bundle has the paired source declared); the
|
||||
// existing skill files are preserved, the missing paired source lands.
|
||||
const result = runScaffold({
|
||||
gbrainRoot,
|
||||
targetWorkspace: ws,
|
||||
skillSlug: 'book-mirror',
|
||||
});
|
||||
|
||||
expect(existsSync(join(ws, 'src', 'commands', 'book-mirror.ts'))).toBe(true);
|
||||
expect(readFileSync(join(ws, 'skills', 'book-mirror', 'SKILL.md'), 'utf-8')).toBe(
|
||||
'# pre-existing skill content\n',
|
||||
);
|
||||
expect(result.summary.pairedSourcesWritten).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runScaffold — dry-run', () => {
|
||||
it('reports outcomes without writing anything', () => {
|
||||
const { gbrainRoot } = scratchGbrain();
|
||||
const ws = scratchWorkspace();
|
||||
|
||||
const result = runScaffold({
|
||||
gbrainRoot,
|
||||
targetWorkspace: ws,
|
||||
skillSlug: 'book-mirror',
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
expect(result.dryRun).toBe(true);
|
||||
expect(result.summary.wroteNew).toBeGreaterThan(0);
|
||||
expect(existsSync(join(ws, 'skills'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runScaffold — error paths', () => {
|
||||
it('unknown skill slug → ScaffoldError(unknown_skill)', () => {
|
||||
const { gbrainRoot } = scratchGbrain();
|
||||
const ws = scratchWorkspace();
|
||||
|
||||
try {
|
||||
runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'does-not-exist' });
|
||||
throw new Error('expected throw');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ScaffoldError);
|
||||
expect((err as ScaffoldError).code).toBe('unknown_skill');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('runScaffold — IRON-RULE regressions (R1, R2)', () => {
|
||||
it('R1: never writes managed-block markers to RESOLVER.md/AGENTS.md', () => {
|
||||
const { gbrainRoot } = scratchGbrain();
|
||||
const ws = scratchWorkspace();
|
||||
|
||||
// Pre-create a RESOLVER.md so we can check it survives untouched.
|
||||
writeFileSync(join(ws, 'RESOLVER.md'), '# my routing\n');
|
||||
|
||||
runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'book-mirror' });
|
||||
|
||||
const resolver = readFileSync(join(ws, 'RESOLVER.md'), 'utf-8');
|
||||
expect(resolver).toBe('# my routing\n');
|
||||
expect(resolver).not.toContain('gbrain:skillpack:begin');
|
||||
expect(resolver).not.toContain('gbrain:skillpack:end');
|
||||
expect(resolver).not.toContain('gbrain:skillpack:manifest');
|
||||
});
|
||||
|
||||
it('R2: never writes a .gbrain-skillpack.lock file', () => {
|
||||
const { gbrainRoot } = scratchGbrain();
|
||||
const ws = scratchWorkspace();
|
||||
|
||||
runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'book-mirror' });
|
||||
|
||||
expect(existsSync(join(ws, '.gbrain-skillpack.lock'))).toBe(false);
|
||||
// Lock should also not exist anywhere in the workspace tree.
|
||||
expect(readdirSync(ws)).not.toContain('.gbrain-skillpack.lock');
|
||||
});
|
||||
|
||||
it('R2: never writes a cumulative-slugs receipt anywhere in workspace', () => {
|
||||
const { gbrainRoot } = scratchGbrain();
|
||||
const ws = scratchWorkspace();
|
||||
|
||||
writeFileSync(join(ws, 'AGENTS.md'), 'existing agents content\n');
|
||||
|
||||
runScaffold({ gbrainRoot, targetWorkspace: ws, skillSlug: 'book-mirror' });
|
||||
|
||||
expect(readFileSync(join(ws, 'AGENTS.md'), 'utf-8')).not.toContain('cumulative-slugs');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user