feat: skills as branches, channels as forks
Replace the custom skills engine with standard git operations. Feature skills are now git branches (on upstream or channel forks) applied via `git merge`. Channels are separate fork repos. - Remove skills-engine/ (6,300+ lines), apply/uninstall/rebase scripts - Remove old skill format (add/, modify/, manifest.yaml) from all skills - Remove old CI (skill-drift.yml, skill-pr.yml) - Add merge-forward CI for upstream skill branches - Add fork notification (repository_dispatch to channel forks) - Add marketplace config (.claude/settings.json) - Add /update-skills operational skill - Update /setup and /customize for marketplace plugin install - Add docs/skills-as-branches.md architecture doc Channel forks created: nanoclaw-whatsapp (with 5 skill branches), nanoclaw-telegram, nanoclaw-discord, nanoclaw-slack, nanoclaw-gmail. Upstream retains: skill/ollama-tool, skill/apple-container, skill/compact. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,24 +0,0 @@
|
||||
import { applySkill } from '../skills-engine/apply.js';
|
||||
import { initNanoclawDir } from '../skills-engine/init.js';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// Handle --init flag: initialize .nanoclaw/ directory and exit
|
||||
if (args.includes('--init')) {
|
||||
initNanoclawDir();
|
||||
console.log(JSON.stringify({ success: true, action: 'init' }));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const skillDir = args[0];
|
||||
if (!skillDir) {
|
||||
console.error('Usage: tsx scripts/apply-skill.ts [--init] <skill-dir>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await applySkill(skillDir);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
if (!result.success) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Auto-fix drifted skills by three-way merging their modify/ files.
|
||||
*
|
||||
* For each drifted skill's `modifies` entry:
|
||||
* 1. Find the commit where the skill's modify/ copy was last updated
|
||||
* 2. Retrieve the source file at that commit (old base)
|
||||
* 3. git merge-file <modify/file> <old_base> <current_main>
|
||||
* - Clean merge → modify/ file is auto-updated
|
||||
* - Conflicts → conflict markers left in place for human/Claude review
|
||||
*
|
||||
* The calling workflow should commit the resulting changes and create a PR.
|
||||
*
|
||||
* Sets GitHub Actions outputs:
|
||||
* has_conflicts — "true" | "false"
|
||||
* fixed_count — number of auto-fixed files
|
||||
* conflict_count — number of files with unresolved conflict markers
|
||||
* summary — human-readable summary for PR body
|
||||
*
|
||||
* Usage: npx tsx scripts/fix-skill-drift.ts add-telegram add-discord
|
||||
*/
|
||||
import { execFileSync, execSync } from 'child_process';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { parse } from 'yaml';
|
||||
import type { SkillManifest } from '../skills-engine/types.js';
|
||||
|
||||
interface FixResult {
|
||||
skill: string;
|
||||
file: string;
|
||||
status: 'auto-fixed' | 'conflict' | 'skipped' | 'error';
|
||||
conflicts?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function readManifest(skillDir: string): SkillManifest {
|
||||
const manifestPath = path.join(skillDir, 'manifest.yaml');
|
||||
return parse(fs.readFileSync(manifestPath, 'utf-8')) as SkillManifest;
|
||||
}
|
||||
|
||||
function fixSkill(skillName: string, projectRoot: string): FixResult[] {
|
||||
const skillDir = path.join(projectRoot, '.claude', 'skills', skillName);
|
||||
const manifest = readManifest(skillDir);
|
||||
const results: FixResult[] = [];
|
||||
|
||||
for (const relPath of manifest.modifies) {
|
||||
const modifyPath = path.join(skillDir, 'modify', relPath);
|
||||
const currentPath = path.join(projectRoot, relPath);
|
||||
|
||||
if (!fs.existsSync(modifyPath)) {
|
||||
results.push({
|
||||
skill: skillName,
|
||||
file: relPath,
|
||||
status: 'skipped',
|
||||
reason: 'modify/ file not found',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(currentPath)) {
|
||||
results.push({
|
||||
skill: skillName,
|
||||
file: relPath,
|
||||
status: 'skipped',
|
||||
reason: 'source file not found on main',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find when the skill's modify file was last changed
|
||||
let lastCommit: string;
|
||||
try {
|
||||
lastCommit = execSync(`git log -1 --format=%H -- "${modifyPath}"`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch {
|
||||
results.push({
|
||||
skill: skillName,
|
||||
file: relPath,
|
||||
status: 'skipped',
|
||||
reason: 'no git history for modify file',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!lastCommit) {
|
||||
results.push({
|
||||
skill: skillName,
|
||||
file: relPath,
|
||||
status: 'skipped',
|
||||
reason: 'no commits found for modify file',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the source file at that commit (the old base the skill was written against)
|
||||
const tmpOldBase = path.join(
|
||||
os.tmpdir(),
|
||||
`nanoclaw-drift-base-${crypto.randomUUID()}`,
|
||||
);
|
||||
try {
|
||||
const oldBase = execSync(`git show "${lastCommit}:${relPath}"`, {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
fs.writeFileSync(tmpOldBase, oldBase);
|
||||
} catch {
|
||||
results.push({
|
||||
skill: skillName,
|
||||
file: relPath,
|
||||
status: 'skipped',
|
||||
reason: `source file not found at commit ${lastCommit.slice(0, 7)}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// If old base == current main, the source hasn't changed since the skill was updated.
|
||||
// The skill is already in sync for this file.
|
||||
const currentContent = fs.readFileSync(currentPath, 'utf-8');
|
||||
const oldBaseContent = fs.readFileSync(tmpOldBase, 'utf-8');
|
||||
if (oldBaseContent === currentContent) {
|
||||
fs.unlinkSync(tmpOldBase);
|
||||
results.push({
|
||||
skill: skillName,
|
||||
file: relPath,
|
||||
status: 'skipped',
|
||||
reason: 'source unchanged since skill update',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Three-way merge: modify/file ← old_base → current_main
|
||||
// git merge-file modifies first argument in-place
|
||||
try {
|
||||
execFileSync('git', ['merge-file', modifyPath, tmpOldBase, currentPath], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
results.push({ skill: skillName, file: relPath, status: 'auto-fixed' });
|
||||
} catch (err: any) {
|
||||
const exitCode = err.status ?? -1;
|
||||
if (exitCode > 0) {
|
||||
// Positive exit code = number of conflicts, file has markers
|
||||
results.push({
|
||||
skill: skillName,
|
||||
file: relPath,
|
||||
status: 'conflict',
|
||||
conflicts: exitCode,
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
skill: skillName,
|
||||
file: relPath,
|
||||
status: 'error',
|
||||
reason: err.message,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(tmpOldBase);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function setOutput(key: string, value: string): void {
|
||||
const outputFile = process.env.GITHUB_OUTPUT;
|
||||
if (!outputFile) return;
|
||||
|
||||
if (value.includes('\n')) {
|
||||
const delimiter = `ghadelim_${Date.now()}`;
|
||||
fs.appendFileSync(
|
||||
outputFile,
|
||||
`${key}<<${delimiter}\n${value}\n${delimiter}\n`,
|
||||
);
|
||||
} else {
|
||||
fs.appendFileSync(outputFile, `${key}=${value}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
const skillNames = process.argv.slice(2);
|
||||
|
||||
if (skillNames.length === 0) {
|
||||
console.error(
|
||||
'Usage: npx tsx scripts/fix-skill-drift.ts <skill1> [skill2] ...',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Attempting auto-fix for: ${skillNames.join(', ')}\n`);
|
||||
|
||||
const allResults: FixResult[] = [];
|
||||
|
||||
for (const skillName of skillNames) {
|
||||
console.log(`--- ${skillName} ---`);
|
||||
const results = fixSkill(skillName, projectRoot);
|
||||
allResults.push(...results);
|
||||
|
||||
for (const r of results) {
|
||||
const icon =
|
||||
r.status === 'auto-fixed'
|
||||
? 'FIXED'
|
||||
: r.status === 'conflict'
|
||||
? `CONFLICT (${r.conflicts})`
|
||||
: r.status === 'skipped'
|
||||
? 'SKIP'
|
||||
: 'ERROR';
|
||||
const detail = r.reason ? ` -- ${r.reason}` : '';
|
||||
console.log(` ${icon} ${r.file}${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
const fixed = allResults.filter((r) => r.status === 'auto-fixed');
|
||||
const conflicts = allResults.filter((r) => r.status === 'conflict');
|
||||
const skipped = allResults.filter((r) => r.status === 'skipped');
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
console.log(` Auto-fixed: ${fixed.length}`);
|
||||
console.log(` Conflicts: ${conflicts.length}`);
|
||||
console.log(` Skipped: ${skipped.length}`);
|
||||
|
||||
// Build markdown summary for PR body
|
||||
const summaryLines: string[] = [];
|
||||
for (const skillName of skillNames) {
|
||||
const skillResults = allResults.filter((r) => r.skill === skillName);
|
||||
const fixedFiles = skillResults.filter((r) => r.status === 'auto-fixed');
|
||||
const conflictFiles = skillResults.filter((r) => r.status === 'conflict');
|
||||
|
||||
summaryLines.push(`### ${skillName}`);
|
||||
if (fixedFiles.length > 0) {
|
||||
summaryLines.push(
|
||||
`Auto-fixed: ${fixedFiles.map((r) => `\`${r.file}\``).join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (conflictFiles.length > 0) {
|
||||
summaryLines.push(
|
||||
`Needs manual resolution: ${conflictFiles.map((r) => `\`${r.file}\``).join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (fixedFiles.length === 0 && conflictFiles.length === 0) {
|
||||
summaryLines.push('No modify/ files needed updating.');
|
||||
}
|
||||
summaryLines.push('');
|
||||
}
|
||||
|
||||
// GitHub outputs
|
||||
setOutput('has_conflicts', conflicts.length > 0 ? 'true' : 'false');
|
||||
setOutput('fixed_count', String(fixed.length));
|
||||
setOutput('conflict_count', String(conflicts.length));
|
||||
setOutput('summary', summaryLines.join('\n'));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/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();
|
||||
@@ -3,7 +3,15 @@ import { execFileSync, execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { compareSemver } from '../skills-engine/state.js';
|
||||
function compareSemver(a: string, b: string): number {
|
||||
const partsA = a.split('.').map(Number);
|
||||
const partsB = b.split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
|
||||
const diff = (partsA[i] || 0) - (partsB[i] || 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Resolve tsx binary once to avoid npx race conditions across migrations
|
||||
function resolveTsx(): string {
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/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);
|
||||
});
|
||||
@@ -1,252 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Validate all skills by applying each in isolation against current main.
|
||||
*
|
||||
* For each skill:
|
||||
* 1. Reset working tree to clean state
|
||||
* 2. Initialize .nanoclaw/ (snapshot current source as base)
|
||||
* 3. Apply skill via apply-skill.ts
|
||||
* 4. Run tsc --noEmit (typecheck)
|
||||
* 5. Run the skill's test command (from manifest.yaml)
|
||||
*
|
||||
* Sets GitHub Actions outputs:
|
||||
* drifted — "true" | "false"
|
||||
* drifted_skills — JSON array of drifted skill names, e.g. ["add-telegram"]
|
||||
* results — JSON array of per-skill results
|
||||
*
|
||||
* Exit code 1 if any skill drifted, 0 otherwise.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/validate-all-skills.ts # validate all
|
||||
* npx tsx scripts/validate-all-skills.ts add-telegram # validate one
|
||||
*/
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { parse } from 'yaml';
|
||||
import type { SkillManifest } from '../skills-engine/types.js';
|
||||
|
||||
interface SkillValidationResult {
|
||||
name: string;
|
||||
success: boolean;
|
||||
failedStep?: 'apply' | 'typecheck' | 'test';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function discoverSkills(
|
||||
skillsDir: string,
|
||||
): { name: string; dir: string; manifest: SkillManifest }[] {
|
||||
if (!fs.existsSync(skillsDir)) return [];
|
||||
const results: { name: string; dir: string; manifest: SkillManifest }[] = [];
|
||||
|
||||
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const manifestPath = path.join(skillsDir, entry.name, 'manifest.yaml');
|
||||
if (!fs.existsSync(manifestPath)) continue;
|
||||
const manifest = parse(
|
||||
fs.readFileSync(manifestPath, 'utf-8'),
|
||||
) as SkillManifest;
|
||||
results.push({
|
||||
name: entry.name,
|
||||
dir: path.join(skillsDir, entry.name),
|
||||
manifest,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Restore tracked files and remove untracked skill artifacts. */
|
||||
function resetWorkingTree(): void {
|
||||
execSync('git checkout -- .', { stdio: 'pipe' });
|
||||
// Remove untracked files added by skill application (e.g. src/channels/telegram.ts)
|
||||
// but preserve node_modules to avoid costly reinstalls.
|
||||
execSync('git clean -fd --exclude=node_modules', { stdio: 'pipe' });
|
||||
// Clean skills-system state directory
|
||||
if (fs.existsSync('.nanoclaw')) {
|
||||
fs.rmSync('.nanoclaw', { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function initNanoclaw(): void {
|
||||
execSync(
|
||||
'npx tsx -e "import { initNanoclawDir } from \'./skills-engine/index\'; initNanoclawDir();"',
|
||||
{ stdio: 'pipe', timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Append a key=value to $GITHUB_OUTPUT (no-op locally). */
|
||||
function setOutput(key: string, value: string): void {
|
||||
const outputFile = process.env.GITHUB_OUTPUT;
|
||||
if (!outputFile) return;
|
||||
|
||||
if (value.includes('\n')) {
|
||||
const delimiter = `ghadelim_${Date.now()}`;
|
||||
fs.appendFileSync(
|
||||
outputFile,
|
||||
`${key}<<${delimiter}\n${value}\n${delimiter}\n`,
|
||||
);
|
||||
} else {
|
||||
fs.appendFileSync(outputFile, `${key}=${value}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(s: string, max = 300): string {
|
||||
return s.length > max ? s.slice(0, max) + '...' : s;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
const skillsDir = path.join(projectRoot, '.claude', 'skills');
|
||||
|
||||
// Allow filtering to specific skills via CLI args
|
||||
const filterSkills = process.argv.slice(2);
|
||||
|
||||
let skills = discoverSkills(skillsDir);
|
||||
if (filterSkills.length > 0) {
|
||||
skills = skills.filter((s) => filterSkills.includes(s.name));
|
||||
}
|
||||
|
||||
if (skills.length === 0) {
|
||||
console.log('No skills found to validate.');
|
||||
setOutput('drifted', 'false');
|
||||
setOutput('drifted_skills', '[]');
|
||||
setOutput('results', '[]');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Validating ${skills.length} skill(s): ${skills.map((s) => s.name).join(', ')}\n`,
|
||||
);
|
||||
|
||||
const results: SkillValidationResult[] = [];
|
||||
|
||||
for (const skill of skills) {
|
||||
console.log(`--- ${skill.name} ---`);
|
||||
|
||||
// Clean slate
|
||||
resetWorkingTree();
|
||||
initNanoclaw();
|
||||
|
||||
// Step 1: Apply skill
|
||||
try {
|
||||
const applyOutput = execSync(
|
||||
`npx tsx scripts/apply-skill.ts "${skill.dir}"`,
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 120_000,
|
||||
},
|
||||
);
|
||||
// parse stdout to verify success
|
||||
try {
|
||||
const parsed = JSON.parse(applyOutput);
|
||||
if (!parsed.success) {
|
||||
console.log(` FAIL (apply): ${truncate(parsed.error || 'unknown')}`);
|
||||
results.push({
|
||||
name: skill.name,
|
||||
success: false,
|
||||
failedStep: 'apply',
|
||||
error: parsed.error,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON stdout with exit 0 is treated as success
|
||||
}
|
||||
} catch (err: any) {
|
||||
const stderr = err.stderr?.toString() || '';
|
||||
const stdout = err.stdout?.toString() || '';
|
||||
let error = 'Apply failed';
|
||||
try {
|
||||
const parsed = JSON.parse(stdout);
|
||||
error = parsed.error || error;
|
||||
} catch {
|
||||
error = stderr || stdout || err.message;
|
||||
}
|
||||
console.log(` FAIL (apply): ${truncate(error)}`);
|
||||
results.push({
|
||||
name: skill.name,
|
||||
success: false,
|
||||
failedStep: 'apply',
|
||||
error,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
console.log(' apply: OK');
|
||||
|
||||
// Step 2: Typecheck
|
||||
try {
|
||||
execSync('npx tsc --noEmit', {
|
||||
stdio: 'pipe',
|
||||
timeout: 120_000,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const error = err.stdout?.toString() || err.message;
|
||||
console.log(` FAIL (typecheck): ${truncate(error)}`);
|
||||
results.push({
|
||||
name: skill.name,
|
||||
success: false,
|
||||
failedStep: 'typecheck',
|
||||
error,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
console.log(' typecheck: OK');
|
||||
|
||||
// Step 3: Skill's own test command
|
||||
if (skill.manifest.test) {
|
||||
try {
|
||||
execSync(skill.manifest.test, {
|
||||
stdio: 'pipe',
|
||||
timeout: 300_000,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const error =
|
||||
err.stdout?.toString() || err.stderr?.toString() || err.message;
|
||||
console.log(` FAIL (test): ${truncate(error)}`);
|
||||
results.push({
|
||||
name: skill.name,
|
||||
success: false,
|
||||
failedStep: 'test',
|
||||
error,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
console.log(' test: OK');
|
||||
}
|
||||
|
||||
console.log(' PASS');
|
||||
results.push({ name: skill.name, success: true });
|
||||
}
|
||||
|
||||
// Restore clean state
|
||||
resetWorkingTree();
|
||||
|
||||
// Summary
|
||||
const drifted = results.filter((r) => !r.success);
|
||||
const passed = results.filter((r) => r.success);
|
||||
|
||||
console.log('\n=== Summary ===');
|
||||
for (const r of results) {
|
||||
const status = r.success ? 'PASS' : 'FAIL';
|
||||
const detail = r.failedStep ? ` (${r.failedStep})` : '';
|
||||
console.log(` ${status} ${r.name}${detail}`);
|
||||
}
|
||||
console.log(`\n${passed.length} passed, ${drifted.length} failed`);
|
||||
|
||||
// GitHub Actions outputs
|
||||
setOutput('drifted', drifted.length > 0 ? 'true' : 'false');
|
||||
setOutput('drifted_skills', JSON.stringify(drifted.map((d) => d.name)));
|
||||
setOutput('results', JSON.stringify(results));
|
||||
|
||||
if (drifted.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user