Skills engine v0.1 + multi-channel infrastructure (#307)
* refactor: multi-channel infrastructure with explicit channel/is_group tracking - Add channels[] array and findChannel() routing in index.ts, replacing hardcoded whatsapp.* calls with channel-agnostic callbacks - Add channel TEXT and is_group INTEGER columns to chats table with COALESCE upsert to protect existing values from null overwrites - is_group defaults to 0 (safe: unknown chats excluded from groups) - WhatsApp passes explicit channel='whatsapp' and isGroup to onChatMetadata - getAvailableGroups filters on is_group instead of JID pattern matching - findChannel logs warnings instead of silently dropping unroutable JIDs - Migration backfills channel/is_group from JID patterns for existing DBs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: skills engine v0.1 — deterministic skill packages with rerere resolution Three-way merge engine for applying skill packages on top of a core codebase. Skills declare which files they add/modify, and the engine uses git merge-file for conflict detection with git rerere for automatic resolution of previously-seen conflicts. Key components: - apply: three-way merge with backup/rollback safety net - replay: clean-slate replay for uninstall and rebase - update: core version updates with deletion detection - rebase: bake applied skills into base (one-way) - manifest: validation with path traversal protection - resolution-cache: pre-computed rerere resolutions - structured: npm deps, env vars, docker-compose merging - CI: per-skill test matrix with conflict detection 151 unit tests covering merge, rerere, backup, replay, uninstall, update, rebase, structured ops, and edge cases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add Discord and Telegram skill packages Skill packages for adding Discord and Telegram channels to NanoClaw. Each package includes: - Channel implementation (add/src/channels/) - Three-way merge targets for index.ts, config.ts, routing.test.ts - Intent docs explaining merge invariants - Standalone integration tests - manifest.yaml with dependency/conflict declarations Applied via: npx tsx scripts/apply-skill.ts .claude/skills/add-discord These are inert until applied — no runtime impact. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * remove unused docs (skills-system-status, implementation-guide) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
14
scripts/apply-skill.ts
Normal file
14
scripts/apply-skill.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { applySkill } from '../skills-engine/apply.js';
|
||||
|
||||
const skillDir = process.argv[2];
|
||||
if (!skillDir) {
|
||||
console.error('Usage: tsx scripts/apply-skill.ts <skill-dir>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await applySkill(skillDir);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
if (!result.success) {
|
||||
process.exit(1);
|
||||
}
|
||||
118
scripts/generate-ci-matrix.ts
Normal file
118
scripts/generate-ci-matrix.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { parse } from 'yaml';
|
||||
import { SkillManifest } from '../skills-engine/types.js';
|
||||
|
||||
export interface MatrixEntry {
|
||||
skills: string[];
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface SkillOverlapInfo {
|
||||
name: string;
|
||||
modifies: string[];
|
||||
npmDependencies: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract overlap-relevant info from a parsed manifest.
|
||||
* @param dirName - The skill's directory name (e.g. 'add-discord'), used in matrix
|
||||
* entries so CI/scripts can locate the skill package on disk.
|
||||
*/
|
||||
export function extractOverlapInfo(manifest: SkillManifest, dirName: string): SkillOverlapInfo {
|
||||
const npmDeps = manifest.structured?.npm_dependencies
|
||||
? Object.keys(manifest.structured.npm_dependencies)
|
||||
: [];
|
||||
|
||||
return {
|
||||
name: dirName,
|
||||
modifies: manifest.modifies ?? [],
|
||||
npmDependencies: npmDeps,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute overlap matrix from a list of skill overlap infos.
|
||||
* Two skills overlap if they share any `modifies` entry or both declare
|
||||
* `structured.npm_dependencies` for the same package.
|
||||
*/
|
||||
export function computeOverlapMatrix(skills: SkillOverlapInfo[]): MatrixEntry[] {
|
||||
const entries: MatrixEntry[] = [];
|
||||
|
||||
for (let i = 0; i < skills.length; i++) {
|
||||
for (let j = i + 1; j < skills.length; j++) {
|
||||
const a = skills[i];
|
||||
const b = skills[j];
|
||||
const reasons: string[] = [];
|
||||
|
||||
// Check shared modifies entries
|
||||
const sharedModifies = a.modifies.filter((m) => b.modifies.includes(m));
|
||||
if (sharedModifies.length > 0) {
|
||||
reasons.push(`shared modifies: ${sharedModifies.join(', ')}`);
|
||||
}
|
||||
|
||||
// Check shared npm_dependencies packages
|
||||
const sharedNpm = a.npmDependencies.filter((pkg) =>
|
||||
b.npmDependencies.includes(pkg),
|
||||
);
|
||||
if (sharedNpm.length > 0) {
|
||||
reasons.push(`shared npm packages: ${sharedNpm.join(', ')}`);
|
||||
}
|
||||
|
||||
if (reasons.length > 0) {
|
||||
entries.push({
|
||||
skills: [a.name, b.name],
|
||||
reason: reasons.join('; '),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all skill manifests from a skills directory (e.g. .claude/skills/).
|
||||
* Each subdirectory should contain a manifest.yaml.
|
||||
* Returns both the parsed manifest and the directory name.
|
||||
*/
|
||||
export function readAllManifests(skillsDir: string): { manifest: SkillManifest; dirName: string }[] {
|
||||
if (!fs.existsSync(skillsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: { manifest: SkillManifest; dirName: string }[] = [];
|
||||
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const manifestPath = path.join(skillsDir, entry.name, 'manifest.yaml');
|
||||
if (!fs.existsSync(manifestPath)) continue;
|
||||
|
||||
const content = fs.readFileSync(manifestPath, 'utf-8');
|
||||
const manifest = parse(content) as SkillManifest;
|
||||
results.push({ manifest, dirName: entry.name });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the full CI matrix from a skills directory.
|
||||
*/
|
||||
export function generateMatrix(skillsDir: string): MatrixEntry[] {
|
||||
const entries = readAllManifests(skillsDir);
|
||||
const overlapInfos = entries.map((e) => extractOverlapInfo(e.manifest, e.dirName));
|
||||
return computeOverlapMatrix(overlapInfos);
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.url.replace('file://', ''))) {
|
||||
const projectRoot = process.cwd();
|
||||
const skillsDir = path.join(projectRoot, '.claude', 'skills');
|
||||
const matrix = generateMatrix(skillsDir);
|
||||
console.log(JSON.stringify(matrix, null, 2));
|
||||
}
|
||||
170
scripts/generate-resolutions.ts
Normal file
170
scripts/generate-resolutions.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Generate rerere-compatible resolution files for known skill combinations.
|
||||
*
|
||||
* For each conflicting file when applying discord after telegram:
|
||||
* 1. Run merge-file to produce conflict markers
|
||||
* 2. Set up rerere adapter — git records preimage and assigns a hash
|
||||
* 3. Capture the hash by diffing rr-cache before/after
|
||||
* 4. Write the correct resolution, git add + git rerere to record postimage
|
||||
* 5. Save preimage, resolution, hash sidecar, and meta to .claude/resolutions/
|
||||
*/
|
||||
import crypto from 'crypto';
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { stringify } from 'yaml';
|
||||
|
||||
import {
|
||||
cleanupMergeState,
|
||||
mergeFile,
|
||||
setupRerereAdapter,
|
||||
} from '../skills-engine/merge.js';
|
||||
import type { FileInputHashes } from '../skills-engine/types.js';
|
||||
|
||||
function sha256(filePath: string): string {
|
||||
const content = fs.readFileSync(filePath);
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const baseDir = '.nanoclaw/base';
|
||||
|
||||
// The files that conflict when applying discord after telegram
|
||||
const conflictFiles = ['src/index.ts', 'src/config.ts', 'src/routing.test.ts'];
|
||||
|
||||
const telegramModify = '.claude/skills/add-telegram/modify';
|
||||
const discordModify = '.claude/skills/add-discord/modify';
|
||||
const shippedResDir = path.join(projectRoot, '.claude', 'resolutions', 'discord+telegram');
|
||||
|
||||
// Get git rr-cache directory
|
||||
const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf-8', cwd: projectRoot }).trim();
|
||||
const rrCacheDir = path.join(
|
||||
path.isAbsolute(gitDir) ? gitDir : path.join(projectRoot, gitDir),
|
||||
'rr-cache',
|
||||
);
|
||||
|
||||
function getRrCacheEntries(): Set<string> {
|
||||
if (!fs.existsSync(rrCacheDir)) return new Set();
|
||||
return new Set(fs.readdirSync(rrCacheDir));
|
||||
}
|
||||
|
||||
// Clear rr-cache to start fresh
|
||||
if (fs.existsSync(rrCacheDir)) {
|
||||
fs.rmSync(rrCacheDir, { recursive: true });
|
||||
}
|
||||
fs.mkdirSync(rrCacheDir, { recursive: true });
|
||||
|
||||
// Prepare output directory
|
||||
if (fs.existsSync(shippedResDir)) {
|
||||
fs.rmSync(shippedResDir, { recursive: true });
|
||||
}
|
||||
|
||||
const results: { relPath: string; hash: string }[] = [];
|
||||
const fileHashes: Record<string, FileInputHashes> = {};
|
||||
|
||||
for (const relPath of conflictFiles) {
|
||||
const basePath = path.join(projectRoot, baseDir, relPath);
|
||||
const oursPath = path.join(projectRoot, telegramModify, relPath);
|
||||
const theirsPath = path.join(projectRoot, discordModify, relPath);
|
||||
|
||||
// Resolution = the correct combined file. Read from existing .resolution files.
|
||||
const existingResFile = path.join(shippedResDir, relPath + '.resolution');
|
||||
// The .resolution files were deleted above, so read from the backup copy
|
||||
const resolutionContent = (() => {
|
||||
// Check if we have a backup from a previous run
|
||||
const backupPath = path.join(projectRoot, '.claude', 'resolutions', '_backup', relPath + '.resolution');
|
||||
if (fs.existsSync(backupPath)) return fs.readFileSync(backupPath, 'utf-8');
|
||||
// Fall back to working tree (only works if both skills are applied)
|
||||
const wtPath = path.join(projectRoot, relPath);
|
||||
return fs.readFileSync(wtPath, 'utf-8');
|
||||
})();
|
||||
|
||||
// Do the merge to produce conflict markers
|
||||
const tmpFile = path.join(os.tmpdir(), `nanoclaw-gen-${Date.now()}-${path.basename(relPath)}`);
|
||||
fs.copyFileSync(oursPath, tmpFile);
|
||||
const result = mergeFile(tmpFile, basePath, theirsPath);
|
||||
|
||||
if (result.clean) {
|
||||
console.log(`${relPath}: clean merge, no resolution needed`);
|
||||
fs.unlinkSync(tmpFile);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute input file hashes for this conflicted file
|
||||
fileHashes[relPath] = {
|
||||
base: sha256(basePath),
|
||||
current: sha256(oursPath), // "ours" = telegram's modify (current state after first skill)
|
||||
skill: sha256(theirsPath), // "theirs" = discord's modify (the skill being applied)
|
||||
};
|
||||
|
||||
const preimageContent = fs.readFileSync(tmpFile, 'utf-8');
|
||||
fs.unlinkSync(tmpFile);
|
||||
|
||||
// Save original working tree file to restore later
|
||||
const origContent = fs.readFileSync(path.join(projectRoot, relPath), 'utf-8');
|
||||
|
||||
// Write conflict markers to working tree for rerere
|
||||
fs.writeFileSync(path.join(projectRoot, relPath), preimageContent);
|
||||
|
||||
// Track rr-cache entries before
|
||||
const entriesBefore = getRrCacheEntries();
|
||||
|
||||
// Set up rerere adapter and let git record the preimage
|
||||
const baseContent = fs.readFileSync(basePath, 'utf-8');
|
||||
const oursContent = fs.readFileSync(oursPath, 'utf-8');
|
||||
const theirsContent = fs.readFileSync(theirsPath, 'utf-8');
|
||||
setupRerereAdapter(relPath, baseContent, oursContent, theirsContent);
|
||||
execSync('git rerere', { stdio: 'pipe', cwd: projectRoot });
|
||||
|
||||
// Find the new rr-cache entry (the hash)
|
||||
const entriesAfter = getRrCacheEntries();
|
||||
const newEntries = [...entriesAfter].filter((e) => !entriesBefore.has(e));
|
||||
|
||||
if (newEntries.length !== 1) {
|
||||
console.error(`${relPath}: expected 1 new rr-cache entry, got ${newEntries.length}`);
|
||||
cleanupMergeState(relPath);
|
||||
fs.writeFileSync(path.join(projectRoot, relPath), origContent);
|
||||
continue;
|
||||
}
|
||||
|
||||
const hash = newEntries[0];
|
||||
|
||||
// Write the resolution and record it
|
||||
fs.writeFileSync(path.join(projectRoot, relPath), resolutionContent);
|
||||
execSync(`git add "${relPath}"`, { stdio: 'pipe', cwd: projectRoot });
|
||||
execSync('git rerere', { stdio: 'pipe', cwd: projectRoot });
|
||||
|
||||
// Clean up
|
||||
cleanupMergeState(relPath);
|
||||
fs.writeFileSync(path.join(projectRoot, relPath), origContent);
|
||||
|
||||
// Save to .claude/resolutions/
|
||||
const outDir = path.join(shippedResDir, path.dirname(relPath));
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const baseName = path.join(shippedResDir, relPath);
|
||||
// Copy preimage and postimage directly from rr-cache (normalized by git)
|
||||
fs.copyFileSync(path.join(rrCacheDir, hash, 'preimage'), baseName + '.preimage');
|
||||
fs.writeFileSync(baseName + '.resolution', resolutionContent);
|
||||
fs.writeFileSync(baseName + '.preimage.hash', hash);
|
||||
|
||||
results.push({ relPath, hash });
|
||||
console.log(`${relPath}: hash=${hash}`);
|
||||
}
|
||||
|
||||
// Write meta.yaml
|
||||
const meta = {
|
||||
skills: ['discord', 'telegram'],
|
||||
apply_order: ['telegram', 'discord'],
|
||||
resolved_at: new Date().toISOString(),
|
||||
tested: true,
|
||||
test_passed: true,
|
||||
resolution_source: 'generated',
|
||||
input_hashes: {},
|
||||
output_hash: '',
|
||||
file_hashes: fileHashes,
|
||||
};
|
||||
fs.writeFileSync(path.join(shippedResDir, 'meta.yaml'), stringify(meta));
|
||||
|
||||
console.log(`\nGenerated ${results.length} resolution(s) in .claude/resolutions/discord+telegram/`);
|
||||
21
scripts/rebase.ts
Normal file
21
scripts/rebase.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
import { rebase } from '../skills-engine/rebase.js';
|
||||
|
||||
async function main() {
|
||||
const newBasePath = process.argv[2]; // optional
|
||||
|
||||
if (newBasePath) {
|
||||
console.log(`Rebasing with new base from: ${newBasePath}`);
|
||||
} else {
|
||||
console.log('Rebasing current state...');
|
||||
}
|
||||
|
||||
const result = await rebase(newBasePath);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
if (!result.success) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
144
scripts/run-ci-tests.ts
Normal file
144
scripts/run-ci-tests.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { generateMatrix, MatrixEntry } from './generate-ci-matrix.js';
|
||||
|
||||
interface TestResult {
|
||||
entry: MatrixEntry;
|
||||
passed: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function copyDirRecursive(src: string, dest: string, exclude: string[] = []): void {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
if (exclude.includes(entry.name)) continue;
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
copyDirRecursive(srcPath, destPath, exclude);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runMatrixEntry(
|
||||
projectRoot: string,
|
||||
entry: MatrixEntry,
|
||||
): Promise<TestResult> {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-ci-'));
|
||||
|
||||
try {
|
||||
// Copy project to temp dir (exclude heavy/irrelevant dirs)
|
||||
copyDirRecursive(projectRoot, tmpDir, [
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'data',
|
||||
'store',
|
||||
'logs',
|
||||
'.nanoclaw',
|
||||
]);
|
||||
|
||||
// Install dependencies
|
||||
execSync('npm install --ignore-scripts', {
|
||||
cwd: tmpDir,
|
||||
stdio: 'pipe',
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
// Initialize nanoclaw dir
|
||||
execSync('npx tsx -e "import { initNanoclawDir } from \'./skills-engine/index.js\'; initNanoclawDir();"', {
|
||||
cwd: tmpDir,
|
||||
stdio: 'pipe',
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Apply each skill in sequence
|
||||
for (const skillName of entry.skills) {
|
||||
const skillDir = path.join(tmpDir, '.claude', 'skills', skillName);
|
||||
if (!fs.existsSync(skillDir)) {
|
||||
return {
|
||||
entry,
|
||||
passed: false,
|
||||
error: `Skill directory not found: ${skillName}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = execSync(
|
||||
`npx tsx scripts/apply-skill.ts "${skillDir}"`,
|
||||
{ cwd: tmpDir, stdio: 'pipe', timeout: 120_000 },
|
||||
);
|
||||
const parsed = JSON.parse(result.toString());
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
entry,
|
||||
passed: false,
|
||||
error: `Failed to apply skill ${skillName}: ${parsed.error}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Run all skill tests
|
||||
execSync('npx vitest run --config vitest.skills.config.ts', {
|
||||
cwd: tmpDir,
|
||||
stdio: 'pipe',
|
||||
timeout: 300_000,
|
||||
});
|
||||
|
||||
return { entry, passed: true };
|
||||
} catch (err: any) {
|
||||
return {
|
||||
entry,
|
||||
passed: false,
|
||||
error: err.message || String(err),
|
||||
};
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
async function main(): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
const skillsDir = path.join(projectRoot, '.claude', 'skills');
|
||||
const matrix = generateMatrix(skillsDir);
|
||||
|
||||
if (matrix.length === 0) {
|
||||
console.log('No overlapping skills found. Nothing to test.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found ${matrix.length} overlapping skill combination(s):\n`);
|
||||
for (const entry of matrix) {
|
||||
console.log(` [${entry.skills.join(', ')}] — ${entry.reason}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
const results: TestResult[] = [];
|
||||
for (const entry of matrix) {
|
||||
console.log(`Testing: [${entry.skills.join(', ')}]...`);
|
||||
const result = await runMatrixEntry(projectRoot, entry);
|
||||
results.push(result);
|
||||
console.log(` ${result.passed ? 'PASS' : 'FAIL'}${result.error ? ` — ${result.error}` : ''}`);
|
||||
}
|
||||
|
||||
console.log('\n--- Summary ---');
|
||||
const passed = results.filter((r) => r.passed).length;
|
||||
const failed = results.filter((r) => !r.passed).length;
|
||||
console.log(`${passed} passed, ${failed} failed out of ${results.length} combination(s)`);
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
37
scripts/uninstall-skill.ts
Normal file
37
scripts/uninstall-skill.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
import { uninstallSkill } from '../skills-engine/uninstall.js';
|
||||
|
||||
async function main() {
|
||||
const skillName = process.argv[2];
|
||||
if (!skillName) {
|
||||
console.error('Usage: npx tsx scripts/uninstall-skill.ts <skill-name>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Uninstalling skill: ${skillName}`);
|
||||
const result = await uninstallSkill(skillName);
|
||||
|
||||
if (result.customPatchWarning) {
|
||||
console.warn(`\nWarning: ${result.customPatchWarning}`);
|
||||
console.warn('To proceed, remove the custom_patch from state.yaml and re-run.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`\nFailed: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\nSuccessfully uninstalled: ${skillName}`);
|
||||
if (result.replayResults) {
|
||||
console.log('Replay test results:');
|
||||
for (const [name, passed] of Object.entries(result.replayResults)) {
|
||||
console.log(` ${name}: ${passed ? 'PASS' : 'FAIL'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
36
scripts/update-core.ts
Normal file
36
scripts/update-core.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env tsx
|
||||
import { applyUpdate, previewUpdate } from '../skills-engine/update.js';
|
||||
|
||||
const newCorePath = process.argv[2];
|
||||
if (!newCorePath) {
|
||||
console.error('Usage: tsx scripts/update-core.ts <path-to-new-core>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Preview
|
||||
const preview = previewUpdate(newCorePath);
|
||||
console.log('=== Update Preview ===');
|
||||
console.log(`Current version: ${preview.currentVersion}`);
|
||||
console.log(`New version: ${preview.newVersion}`);
|
||||
console.log(`Files changed: ${preview.filesChanged.length}`);
|
||||
if (preview.filesChanged.length > 0) {
|
||||
for (const f of preview.filesChanged) {
|
||||
console.log(` ${f}`);
|
||||
}
|
||||
}
|
||||
if (preview.conflictRisk.length > 0) {
|
||||
console.log(`Conflict risk: ${preview.conflictRisk.join(', ')}`);
|
||||
}
|
||||
if (preview.customPatchesAtRisk.length > 0) {
|
||||
console.log(`Custom patches at risk: ${preview.customPatchesAtRisk.join(', ')}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Apply
|
||||
console.log('Applying update...');
|
||||
const result = await applyUpdate(newCorePath);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
if (!result.success) {
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user