Files
gbrain/src/core/skillpack/installer.ts
T
11abb24ddd v0.20.4 feat: merge gbrain-jobs into minion-orchestrator — single unified minions skill (#381)
* feat: merge gbrain-jobs into minion-orchestrator — single unified minions skill

* fix(skill/minion-orchestrator): correct MCP boundary, real handler names, PGLite path

The initial merge commit a51c737 documented `submit_job name="shell"` as
agent-callable, but src/core/operations.ts:1106 rejects protected names
from MCP callers (shell is in src/core/minions/protected-names.ts:16) —
shell-job submission is CLI-only. Subagent examples referenced non-existent
handler names (`research`, `orchestrate`) instead of the real `subagent` /
`subagent_aggregator` handlers. PGLite section wrongly told users to
migrate to Supabase when `gbrain jobs submit ... --follow` inline mode
works per docs/guides/minions-shell-jobs.md:15. Contract section canonized
"every task through Minions" against the `pain_triggered` default in
skills/conventions/subagent-routing.md:16,27.

Rewrite addresses all four:
- Shell Jobs section is explicit about CLI-only submission; agents observe
  via get_job / list_jobs / get_job_progress (non-protected).
- Subagent examples route through `gbrain agent run` (user-facing CLI)
  with raw handler names documented as the power-user path.
- PGLite gets --follow inline execution, not migration friction.
- Contract softened to point at subagent-routing.md convention.

Also adds a Preconditions block for Shell Jobs (env gate, RCE warning,
execution-mode choice, verification command), narrows the frontmatter
"gbrain jobs" trigger to "gbrain jobs submit" + "submit a gbrain job"
(bare was too broad — CLI namespace covers 9 subcommands), inlines a
"replaces older gbrain-jobs routing intent" note in the description, and
removes non-existent `get_job_stats` from the tools list (CLI is
`gbrain jobs stats`; no MCP equivalent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(resolver): narrow "gbrain jobs" trigger to specific intents

Replace bare "gbrain jobs" in the routing table with "gbrain jobs submit"
+ "submit a gbrain job". The bare phrase was too broad — the CLI namespace
covers 9 subcommands (submit, list, get, retry, delete, prune, stats,
smoke, work). Users asking about stats/prune/retry now fall through to
`gbrain --help` instead of getting misrouted to minion-orchestrator, which
only documents shell execution and subagent orchestration.

Matches the frontmatter trigger narrow in minion-orchestrator/SKILL.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(resolver): add round-trip + skill-example-name validator

Two new assertion blocks in test/resolver.test.ts:

1. RESOLVER.md trigger round-trip: every quoted phrase in a routing-table
   row has a fuzzy match in the target skill's frontmatter `triggers:` list.
   Catches RESOLVER ↔ frontmatter drift that checkResolvable's reachability
   check doesn't. Fuzzy match is case-insensitive, trailing-punctuation-
   insensitive, and splits on "/" for compound phrases like
   "pause/resume agent" — accommodates RESOLVER.md's natural-language
   summary style without allowing real drift through.

2. Skill example-name validator: every `name="<word>"` reference in any
   SKILL.md body must resolve to either a declared operation in
   src/core/operations.ts or a known Minions handler in
   PROTECTED_JOB_NAMES. Would have caught the `name="research"` /
   `name="orchestrate"` drift that slipped through the first review
   — nothing in CI caught those handler names referencing non-existent
   handlers until a Codex cold-read found them. This test closes that
   class of regression gap.

51 / 51 tests pass locally. Full E2E suite (bun run test:e2e) still
passes 197 / 197 across 19 files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): PGLite shell-job --follow inline path

Closes the T4 coverage gap surfaced during PR #381 eng review. The sibling
test/e2e/minions-shell.test.ts covers Postgres + persistent-daemon; this
file covers the PGLite + --follow path the minion-orchestrator skill now
documents.

Two assertions:

1. submit → registerBuiltinHandlers → worker.start → shell runs → completes
   with exit_code 0 and stdout_tail "hello\n". Exercises the exact dispatch
   path src/commands/jobs.ts:207 takes when --follow is set, including the
   GBRAIN_ALLOW_SHELL_JOBS=1 gate.

2. With GBRAIN_ALLOW_SHELL_JOBS unset, registerBuiltinHandlers leaves the
   shell handler unregistered. Confirms the env gate from
   src/commands/jobs.ts:611 works.

Runs in-memory against PGLiteEngine — no DATABASE_URL, no Docker, runs in
CI unconditionally. Completes in ~1.2s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: pre-landing review fixes

Pre-landing review caught 4 doc bugs + 2 test fragilities + 2 pre-existing
drift cases. All auto-fix category (clear correct answer, single obvious fix).

minion-orchestrator/SKILL.md:
- Shell submit examples used nonexistent `--cmd`/`--argv`/`--cwd` flags. Real
  CLI takes `--params '{"cmd":"...","cwd":"..."}'` (src/commands/jobs.ts:55-85).
  Examples now match `gbrain jobs submit --help` output.
- `--tools "search,web_search"` referenced `web_search` which isn't in
  BRAIN_TOOL_ALLOWLIST (src/core/minions/tools/brain-allowlist.ts:47-59).
  Swapped to `search,query`. Added a full allowlist enumeration so
  readers don't have to grep.
- `gbrain agent run` flags section listed `--queue`, `--priority`,
  `--max-attempts`, `--delay` — none of these exist on that command
  (src/commands/agent.ts:105-129). Replaced with the real flag set
  (`--subagent-def`, `--model`, `--max-turns`, `--tools`, `--timeout-ms`,
  `--fanout-manifest`, `--follow`, `--no-follow`, `--detach`) and a note
  about using `gbrain jobs submit` for queue tuning.
- MCP boundary claim "returns permission_denied" was imprecise. Reworded:
  throws an OperationError with code permission_denied.

test/resolver.test.ts:
- D5/C row regex required the backtick-quoted skill path to be followed
  immediately by `|`, silently skipping rows with trailing parentheticals
  (e.g., `` `skills/maintain/SKILL.md` (extraction sections) |``). Broadened
  to `[^|]*\|` so every row gets audited.

test/e2e/minions-shell-pglite.test.ts:
- Shared engine across both tests with no per-test reset. Future test
  additions would hit order-dependency. Added beforeEach TRUNCATE on
  minion_jobs / minion_inbox / minion_attachments, matching the Postgres
  sibling at test/e2e/minions-shell.test.ts:55-58.

skills/query/SKILL.md:
- Added 4 triggers RESOLVER.md routes to this skill but the frontmatter
  never declared: "who knows who", "relationship between", "connections",
  "graph query". Pre-existing drift — the broadened D5/C regex surfaced it.

skills/maintain/SKILL.md:
- Added 6 triggers with the same pre-existing drift: "extract links",
  "build link graph", "populate timeline", "populate links", "backfill graph",
  "extract timeline entries".

57/57 tests pass on the fixed tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: second-pass review fixes — stale CLI flag + handler name

Two more stale references caught by specialist re-dispatch on the fixed tree:

skills/minion-orchestrator/SKILL.md:72 — Routing table row described shell
  jobs as taking `--cmd` or `--argv` as CLI flags. Same class of bug as M1
  from the prior fix commit but in a different location. Now says `--params`
  with `cmd` or `argv`, matching the corrected submit examples (lines 112-120).

skills/conventions/subagent-routing.md:82 — "Check `get_job_stats`
  queue_health.active" referenced an MCP operation that doesn't exist in
  src/core/operations.ts. The new minion-orchestrator skill cross-references
  this convention file, so agents following the routing pointer would hit a
  non-existent op. Replaced with the real ops: `list_jobs --status active`
  (MCP) or `gbrain jobs stats` (CLI).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: adversarial pass cleanups — manifest.json + anti-pattern scope

Claude adversarial subagent caught two last consistency gaps:

skills/manifest.json:135 — Skill description still read "Manage background
  agents via Minions job queue" (subagent-only framing), out of sync with
  the reframed SKILL.md frontmatter. Manifest is what the skill registry
  indexes; leaving this stale meant shell-job-intent routers would miss it.
  Updated to match the unified wording.

skills/minion-orchestrator/SKILL.md:288 — Anti-pattern line "Don't use
  sessions_spawn with runtime: subagent when Minions is available" was
  subagent-lane-specific inside the now-consolidated skill, reading like
  the one rule in the skill but only addressing one lane. Scoped to
  "For subagent work" and pointed at `gbrain agent run` so the rule
  doesn't confuse shell-job readers.

Two investigate-class items deferred to follow-up:
- D13 regex could false-positive on future skills with unrelated `name="..."`
  usage. Today clean; scope to backtick-fenced snippets if it bites.
- PGLite E2E env-var race if bun:test ever goes file-parallel. Today isolated
  per file; add helper + comment when needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.19.2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: update README + CLAUDE.md for v0.19.2 Minions consolidation

- Skill count 28 -> 29 across README and CLAUDE.md (adds smoke-test from
  v0.19.1 to the Skills section, closes a prior drift).
- README minion-orchestrator row rewritten to name both lanes (shell jobs
  via `gbrain jobs submit shell`, LLM subagents via `gbrain agent run`)
  so the surface matches the consolidated skill file.
- README Operational table gains a smoke-test row.
- CLAUDE.md key-files entry for minion-orchestrator now describes the
  v0.19.2 consolidation, trust boundary (MCP permission_denied on
  protected names), and the narrowed trigger set.
- CLAUDE.md Skills section notes the consolidation and the new v0.19.1
  smoke-test skill.
- CLAUDE.md test inventory picks up `test/e2e/minions-shell-pglite.test.ts`
  and the v0.19.2 round-trip + name-validator additions in
  `test/resolver.test.ts`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(ci): update PGLite test for new env-gate behavior + regenerate llms-full.txt

CI caught two issues:

1. `test/e2e/minions-shell-pglite.test.ts` — the "GBRAIN_ALLOW_SHELL_JOBS
   unset → shell handler not registered" test was written against pre-v0.20.3
   `registerBuiltinHandlers` behavior (env gate at registration time). Master's
   queue-resilience merge moved the gate from registration to execution:
   shell handler is now always registered so claimed jobs emit a clear rejection
   log, and `shellHandler` itself throws UnrecoverableError when
   GBRAIN_ALLOW_SHELL_JOBS != '1' (see src/core/minions/handlers/shell.ts:210).
   Updated the test to invoke shellHandler directly with a minimal ctx and
   assert the throw. Preserves the test's intent (prove the guard works) under
   the new control flow.

2. `llms-full.txt` drift — README.md + CLAUDE.md updates in v0.19.2 and v0.20.4
   updated the skill count to 29 and rewrote the minion-orchestrator
   description, but the committed `llms-full.txt` bundle still reflected the
   pre-consolidation content. Regenerated via `bun run build:llms`.

The third CI failure (`planInstall + applyInstall D-CX-11`) passes cleanly
locally (26/26 in test/skillpack-install.test.ts). The 1ms runtime in CI
suggests a filesystem-mtime flake, not a real regression from this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(skillpack): treat future-mtime lock as stale (CI race fix)

D-CX-11 ("--force-unlock overrides a stale lock") flaked in CI with a 1ms
runtime. Root cause: on fast CI filesystems (ext4 with high-resolution
mtimes on GitHub runners), `writeFileSync` can set a lock's mtime a few
microseconds ahead of the subsequent `Date.now()`, making `age` negative.

Old logic:
  const stale = age >= staleMs;

With `staleMs: 0` and `age = -0.3ms`: `-0.3 >= 0` is false → NOT stale →
the `!stale` branch throws `lock_held` before reaching the force-unlock
path. Test failed at the first ms, never exercised the actual unlock logic.

Fix (src/core/skillpack/installer.ts:189):
  const stale = age < 0 || age >= staleMs;

Treats negative age (future mtime) as stale. Safe: if the lock's mtime is
in the future, either the filesystem clock just jumped forward or the
lock was written by a racing process; either way it's not a live,
healthy lock and the stale path is the correct branch.

Passes locally (26/26 in test/skillpack-install.test.ts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: root <root@localhost>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:39:58 -07:00

457 lines
13 KiB
TypeScript

/**
* skillpack/installer.ts — copy bundle files into a target OpenClaw
* workspace, atomically and with data-loss protection.
*
* Contracts (from codex outside-voice review):
* - Per-file diff protection (D-CX-3 / F4): if a target file differs
* from the bundle source, skip it unless `--overwrite-local` is
* passed. `--force` only bypasses the top-level "skill dir already
* exists" gate.
* - Dependency closure (D-CX-10): every skill install pulls the
* full `shared_deps` set so cross-references don't break.
* - Concurrency / lockfile (D-CX-11): acquire `.gbrain-skillpack.lock`
* before any write. Atomic AGENTS.md managed-block update via
* tmp + rename. Stale lock (>10 min PID mismatch) emits a warning
* and refuses to overwrite unless `--force-unlock`.
*/
import {
closeSync,
existsSync,
mkdirSync,
openSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
statSync,
unlinkSync,
writeFileSync,
writeSync,
} from 'fs';
import { dirname, join } from 'path';
import {
enumerateBundle,
loadBundleManifest,
pathSlug,
type BundleEntry,
type BundleManifest,
} from './bundle.ts';
import { findResolverFile } from '../resolver-filenames.ts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type FileOutcome =
| 'wrote_new'
| 'wrote_overwrite'
| 'skipped_locally_modified'
| 'skipped_identical'
| 'skipped_overwrite_local_declined';
export interface FileResult {
source: string;
target: string;
outcome: FileOutcome;
sharedDep: boolean;
}
export interface ManagedBlockResult {
resolverFile: string;
applied: boolean;
skippedReason?: 'resolver_not_found' | 'no_change';
}
export interface InstallPlan {
gbrainRoot: string;
targetSkillsDir: string;
targetWorkspace: string;
entries: BundleEntry[];
manifest: BundleManifest;
/** Computed diffs per entry — populated in planInstall, consumed by apply. */
entryOutcomes: Array<{ entry: BundleEntry; existing: boolean; identical: boolean }>;
}
export interface InstallOptions {
/** Absolute path to the target workspace (above skills/). */
targetWorkspace: string;
/** Absolute path to the target skills directory. */
targetSkillsDir: string;
/** Gbrain repo root (source). Defaults to the one found by findGbrainRoot. */
gbrainRoot: string;
/** Scope to a single skill slug, or `null` for --all. */
skillSlug: string | null;
/** Overwrite local files that differ from the bundle source. */
overwriteLocal?: boolean;
/** Dry-run: populate plan, do not write. */
dryRun?: boolean;
/** Forcibly proceed even when a stale lockfile exists. */
forceUnlock?: boolean;
/** Override the lock stale threshold (ms). Tests use this. */
lockStaleMs?: number;
}
export class InstallError extends Error {
constructor(
message: string,
public code:
| 'lock_held'
| 'bundle_error'
| 'target_missing'
| 'unknown_skill',
) {
super(message);
this.name = 'InstallError';
}
}
const DEFAULT_LOCK_STALE_MS = 10 * 60 * 1000; // 10 minutes
// ---------------------------------------------------------------------------
// Plan
// ---------------------------------------------------------------------------
/**
* Build an InstallPlan for either a single skill (by slug) or every
* skill (`skillSlug: null`). Always returns the full dependency
* closure (shared_deps + target skills).
*/
export function planInstall(opts: InstallOptions): InstallPlan {
const manifest = loadBundleManifest(opts.gbrainRoot);
const entries = enumerateBundle({
gbrainRoot: opts.gbrainRoot,
skillSlug: opts.skillSlug ?? undefined,
manifest,
});
const entryOutcomes = entries.map(e => {
const target = join(opts.targetSkillsDir, e.relTarget);
const existing = existsSync(target);
let identical = false;
if (existing) {
try {
const a = readFileSync(e.source);
const b = readFileSync(target);
identical = a.equals(b);
} catch {
identical = false;
}
}
return { entry: e, existing, identical };
});
return {
gbrainRoot: opts.gbrainRoot,
targetSkillsDir: opts.targetSkillsDir,
targetWorkspace: opts.targetWorkspace,
entries,
manifest,
entryOutcomes,
};
}
// ---------------------------------------------------------------------------
// Lockfile (D-CX-11)
// ---------------------------------------------------------------------------
function lockPath(workspace: string): string {
return join(workspace, '.gbrain-skillpack.lock');
}
interface LockInfo {
pid: number;
mtimeMs: number;
}
function readLock(workspace: string): LockInfo | null {
const p = lockPath(workspace);
if (!existsSync(p)) return null;
try {
const content = readFileSync(p, 'utf-8');
const pid = parseInt(content.trim(), 10);
const mtimeMs = statSync(p).mtimeMs;
return { pid: isNaN(pid) ? -1 : pid, mtimeMs };
} catch {
return null;
}
}
function acquireLock(workspace: string, opts: InstallOptions): void {
const p = lockPath(workspace);
const existing = readLock(workspace);
const staleMs = opts.lockStaleMs ?? DEFAULT_LOCK_STALE_MS;
if (existing) {
const age = Date.now() - existing.mtimeMs;
// `staleMs: 0` in tests means "any age counts as stale". Use >=
// so a just-written lock qualifies when the threshold is 0.
// Negative age (mtime in the future) happens on fast CI filesystems
// where write → stat roundtrip returns an mtime microseconds ahead of
// Date.now() — treat it as stale to avoid a "lock held" false positive.
const stale = age < 0 || age >= staleMs;
if (stale && !opts.forceUnlock) {
throw new InstallError(
`Stale skillpack lock at ${p} (pid ${existing.pid}, ${Math.round(age / 1000)}s old). Pass --force-unlock to proceed.`,
'lock_held',
);
}
if (stale && opts.forceUnlock) {
try {
unlinkSync(p);
} catch {
// fall through to write
}
} else if (!stale) {
throw new InstallError(
`Another skillpack install appears to be running (pid ${existing.pid}). Wait or pass --force-unlock.`,
'lock_held',
);
}
}
mkdirSync(dirname(p), { recursive: true });
const fd = openSync(p, 'w');
try {
writeSync(fd, String(process.pid));
} finally {
closeSync(fd);
}
}
function releaseLock(workspace: string): void {
const p = lockPath(workspace);
try {
unlinkSync(p);
} catch {
// best-effort
}
}
// ---------------------------------------------------------------------------
// Managed block (AGENTS.md / RESOLVER.md)
// ---------------------------------------------------------------------------
const MANAGED_BEGIN = '<!-- gbrain:skillpack:begin -->';
const MANAGED_END = '<!-- gbrain:skillpack:end -->';
export function buildManagedBlock(manifest: BundleManifest, slugs: string[]): string {
const sorted = [...slugs].sort();
const rows = sorted.map(
slug => `| "${slug}" | \`skills/${slug}/SKILL.md\` |`,
);
return [
MANAGED_BEGIN,
'',
`<!-- Installed by gbrain ${manifest.version} — do not hand-edit between markers. -->`,
'',
'| Trigger | Skill |',
'|---------|-------|',
...rows,
'',
MANAGED_END,
].join('\n');
}
/**
* Replace the managed block in `resolverContent` with `newBlock`. If
* no managed block exists yet, append one (preceded by a blank line).
*/
export function updateManagedBlock(
resolverContent: string,
newBlock: string,
): string {
const beginIdx = resolverContent.indexOf(MANAGED_BEGIN);
const endIdx = resolverContent.indexOf(MANAGED_END);
if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
const before = resolverContent.slice(0, beginIdx);
const after = resolverContent.slice(endIdx + MANAGED_END.length);
return before + newBlock + after;
}
const needsNewline = resolverContent.endsWith('\n') ? '' : '\n';
return resolverContent + needsNewline + '\n' + newBlock + '\n';
}
function writeAtomic(file: string, content: string): void {
const tmp = file + '.tmp.' + process.pid + '.' + Date.now();
mkdirSync(dirname(file), { recursive: true });
writeFileSync(tmp, content);
renameSync(tmp, file);
}
// ---------------------------------------------------------------------------
// Apply
// ---------------------------------------------------------------------------
export interface InstallResult {
dryRun: boolean;
files: FileResult[];
managedBlock: ManagedBlockResult;
summary: {
wroteNew: number;
wroteOverwrite: number;
skippedIdentical: number;
skippedLocallyModified: number;
};
}
export function applyInstall(
plan: InstallPlan,
opts: InstallOptions,
): InstallResult {
const files: FileResult[] = [];
// Lock acquisition. Dry-run does NOT touch the lockfile — it's read-only.
const shouldLock = !opts.dryRun;
if (shouldLock) acquireLock(opts.targetWorkspace, opts);
try {
// Write files
for (const { entry, existing, identical } of plan.entryOutcomes) {
const target = join(plan.targetSkillsDir, entry.relTarget);
let outcome: FileOutcome;
if (!existing) {
outcome = 'wrote_new';
if (!opts.dryRun) {
const content = readFileSync(entry.source);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, content);
}
} else if (identical) {
outcome = 'skipped_identical';
} else if (opts.overwriteLocal) {
outcome = 'wrote_overwrite';
if (!opts.dryRun) {
const content = readFileSync(entry.source);
writeFileSync(target, content);
}
} else {
outcome = 'skipped_locally_modified';
}
files.push({
source: entry.source,
target,
outcome,
sharedDep: entry.sharedDep,
});
}
// Managed block update
const installedSlugs = opts.skillSlug
? [opts.skillSlug]
: plan.manifest.skills.map(pathSlug);
const managedBlock = applyManagedBlock(
plan.targetWorkspace,
plan.targetSkillsDir,
plan.manifest,
installedSlugs,
opts.dryRun ?? false,
);
const summary = {
wroteNew: files.filter(f => f.outcome === 'wrote_new').length,
wroteOverwrite: files.filter(f => f.outcome === 'wrote_overwrite').length,
skippedIdentical: files.filter(f => f.outcome === 'skipped_identical').length,
skippedLocallyModified: files.filter(
f => f.outcome === 'skipped_locally_modified',
).length,
};
return { dryRun: opts.dryRun ?? false, files, managedBlock, summary };
} finally {
if (shouldLock) releaseLock(opts.targetWorkspace);
}
}
function applyManagedBlock(
workspace: string,
skillsDir: string,
manifest: BundleManifest,
installedSlugs: string[],
dryRun: boolean,
): ManagedBlockResult {
// Prefer skills-dir resolver; fall back to workspace-root resolver.
const resolver = findResolverFile(skillsDir) ?? findResolverFile(workspace);
if (!resolver) {
return {
resolverFile: '',
applied: false,
skippedReason: 'resolver_not_found',
};
}
const existing = readFileSync(resolver, 'utf-8');
// Merge with any slugs already present in the managed block so
// repeated single-skill installs accumulate rather than overwrite.
const priorSlugs = extractManagedSlugs(existing);
const merged = Array.from(new Set([...priorSlugs, ...installedSlugs]));
const newBlock = buildManagedBlock(manifest, merged);
const updated = updateManagedBlock(existing, newBlock);
if (updated === existing) {
return { resolverFile: resolver, applied: false, skippedReason: 'no_change' };
}
if (!dryRun) writeAtomic(resolver, updated);
return { resolverFile: resolver, applied: true };
}
export function extractManagedSlugs(resolverContent: string): string[] {
const beginIdx = resolverContent.indexOf(MANAGED_BEGIN);
const endIdx = resolverContent.indexOf(MANAGED_END);
if (beginIdx === -1 || endIdx === -1 || endIdx <= beginIdx) return [];
const block = resolverContent.slice(beginIdx, endIdx);
const slugs: string[] = [];
const re = /`skills\/([^/]+)\/SKILL\.md`/g;
let m: RegExpExecArray | null;
while ((m = re.exec(block)) !== null) {
slugs.push(m[1]);
}
return slugs;
}
// ---------------------------------------------------------------------------
// Diff helpers (for `skillpack diff <name>`)
// ---------------------------------------------------------------------------
export interface SkillDiff {
source: string;
target: string;
existing: boolean;
identical: boolean;
sourceBytes: number;
targetBytes: number;
}
export function diffSkill(
gbrainRoot: string,
skillSlug: string,
targetSkillsDir: string,
): SkillDiff[] {
const manifest = loadBundleManifest(gbrainRoot);
const entries = enumerateBundle({ gbrainRoot, skillSlug, manifest });
const out: SkillDiff[] = [];
for (const e of entries) {
const target = join(targetSkillsDir, e.relTarget);
const existing = existsSync(target);
let identical = false;
let targetBytes = 0;
const sourceBytes = statSync(e.source).size;
if (existing) {
try {
const a = readFileSync(e.source);
const b = readFileSync(target);
targetBytes = b.length;
identical = a.equals(b);
} catch {
// treat as non-identical
}
}
out.push({
source: e.source,
target,
existing,
identical,
sourceBytes,
targetBytes,
});
}
return out;
}