feat: add /update skill for pulling upstream changes (#372)

Interactive skill that guides Claude through fetching upstream NanoClaw,
previewing changes, merging with customizations, running migrations, and
verifying the result. Includes:

- SKILL.md with 9-step update flow
- fetch-upstream.sh: detects remote, fetches, extracts tracked paths
- run-migrations.ts: discovers and runs version-ordered migrations
- post-update.ts: clears backup after conflict resolution
- update-core.ts: adds --json and --preview-only flags
- BASE_INCLUDES moved to constants.ts as single source of truth
- 16 new tests covering fetch, migrations, and CLI flags

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-02-23 01:03:13 +02:00
committed by GitHub
parent 628d434799
commit 1216b5b99c
10 changed files with 1026 additions and 22 deletions

View File

@@ -0,0 +1,249 @@
import { execFileSync, execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
describe('fetch-upstream.sh', () => {
let projectDir: string;
let upstreamBareDir: string;
const scriptPath = path.resolve(
'.claude/skills/update/scripts/fetch-upstream.sh',
);
beforeEach(() => {
// Create a bare repo to act as "upstream"
upstreamBareDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'nanoclaw-upstream-'),
);
execSync('git init --bare', { cwd: upstreamBareDir, stdio: 'pipe' });
// Create a working repo, add files, push to the bare repo
const seedDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'nanoclaw-seed-'),
);
execSync('git init', { cwd: seedDir, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', {
cwd: seedDir,
stdio: 'pipe',
});
execSync('git config user.name "Test"', { cwd: seedDir, stdio: 'pipe' });
fs.writeFileSync(
path.join(seedDir, 'package.json'),
JSON.stringify({ name: 'nanoclaw', version: '2.0.0' }),
);
fs.mkdirSync(path.join(seedDir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(seedDir, 'src/index.ts'),
'export const v = 2;',
);
execSync('git add -A && git commit -m "upstream v2.0.0"', {
cwd: seedDir,
stdio: 'pipe',
});
execSync(`git remote add origin ${upstreamBareDir}`, {
cwd: seedDir,
stdio: 'pipe',
});
execSync('git push origin main 2>/dev/null || git push origin master', {
cwd: seedDir,
stdio: 'pipe',
shell: '/bin/bash',
});
// Rename the default branch to main in the bare repo if needed
try {
execSync('git symbolic-ref HEAD refs/heads/main', {
cwd: upstreamBareDir,
stdio: 'pipe',
});
} catch {
// Already on main
}
fs.rmSync(seedDir, { recursive: true, force: true });
// Create the "project" repo that will run the script
projectDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'nanoclaw-project-'),
);
execSync('git init', { cwd: projectDir, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', {
cwd: projectDir,
stdio: 'pipe',
});
execSync('git config user.name "Test"', {
cwd: projectDir,
stdio: 'pipe',
});
fs.writeFileSync(
path.join(projectDir, 'package.json'),
JSON.stringify({ name: 'nanoclaw', version: '1.0.0' }),
);
execSync('git add -A && git commit -m "init"', {
cwd: projectDir,
stdio: 'pipe',
});
// Copy skills-engine/constants.ts so fetch-upstream.sh can read BASE_INCLUDES
const constantsSrc = path.resolve('skills-engine/constants.ts');
const constantsDest = path.join(projectDir, 'skills-engine/constants.ts');
fs.mkdirSync(path.dirname(constantsDest), { recursive: true });
fs.copyFileSync(constantsSrc, constantsDest);
// Copy the script into the project so it can find PROJECT_ROOT
const skillScriptsDir = path.join(
projectDir,
'.claude/skills/update/scripts',
);
fs.mkdirSync(skillScriptsDir, { recursive: true });
fs.copyFileSync(scriptPath, path.join(skillScriptsDir, 'fetch-upstream.sh'));
fs.chmodSync(path.join(skillScriptsDir, 'fetch-upstream.sh'), 0o755);
});
afterEach(() => {
// Clean up temp dirs (also any TEMP_DIR created by the script)
for (const dir of [projectDir, upstreamBareDir]) {
if (dir && fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
});
function runFetchUpstream(): { stdout: string; exitCode: number } {
try {
const stdout = execFileSync(
'bash',
['.claude/skills/update/scripts/fetch-upstream.sh'],
{
cwd: projectDir,
encoding: 'utf-8',
stdio: 'pipe',
timeout: 30_000,
},
);
return { stdout, exitCode: 0 };
} catch (err: any) {
return { stdout: (err.stdout ?? '') + (err.stderr ?? ''), exitCode: err.status ?? 1 };
}
}
function parseStatus(stdout: string): Record<string, string> {
const match = stdout.match(/<<< STATUS\n([\s\S]*?)\nSTATUS >>>/);
if (!match) return {};
const lines = match[1].trim().split('\n');
const result: Record<string, string> = {};
for (const line of lines) {
const eq = line.indexOf('=');
if (eq > 0) {
result[line.slice(0, eq)] = line.slice(eq + 1);
}
}
return result;
}
it('uses existing upstream remote', () => {
execSync(`git remote add upstream ${upstreamBareDir}`, {
cwd: projectDir,
stdio: 'pipe',
});
const { stdout, exitCode } = runFetchUpstream();
const status = parseStatus(stdout);
expect(exitCode).toBe(0);
expect(status.STATUS).toBe('success');
expect(status.REMOTE).toBe('upstream');
expect(status.CURRENT_VERSION).toBe('1.0.0');
expect(status.NEW_VERSION).toBe('2.0.0');
expect(status.TEMP_DIR).toMatch(/^\/tmp\/nanoclaw-update-/);
// Verify extracted files exist
expect(
fs.existsSync(path.join(status.TEMP_DIR, 'package.json')),
).toBe(true);
expect(
fs.existsSync(path.join(status.TEMP_DIR, 'src/index.ts')),
).toBe(true);
// Cleanup temp dir
fs.rmSync(status.TEMP_DIR, { recursive: true, force: true });
});
it('uses origin when it points to qwibitai/nanoclaw', () => {
// Set origin to a URL containing qwibitai/nanoclaw
execSync(
`git remote add origin https://github.com/qwibitai/nanoclaw.git`,
{ cwd: projectDir, stdio: 'pipe' },
);
// We can't actually fetch from GitHub in tests, but we can verify
// it picks the right remote. We'll add a second remote it CAN fetch from.
execSync(`git remote add upstream ${upstreamBareDir}`, {
cwd: projectDir,
stdio: 'pipe',
});
const { stdout, exitCode } = runFetchUpstream();
const status = parseStatus(stdout);
// It should find 'upstream' first (checked before origin)
expect(exitCode).toBe(0);
expect(status.REMOTE).toBe('upstream');
if (status.TEMP_DIR) {
fs.rmSync(status.TEMP_DIR, { recursive: true, force: true });
}
});
it('adds upstream remote when none exists', { timeout: 15_000 }, () => {
// Remove origin if any
try {
execSync('git remote remove origin', {
cwd: projectDir,
stdio: 'pipe',
});
} catch {
// No origin
}
const { stdout } = runFetchUpstream();
// It will try to add upstream pointing to github (which will fail to fetch),
// but we can verify it attempted to add the remote
expect(stdout).toContain('Adding upstream');
// Verify the remote was added
const remotes = execSync('git remote -v', {
cwd: projectDir,
encoding: 'utf-8',
});
expect(remotes).toContain('upstream');
expect(remotes).toContain('qwibitai/nanoclaw');
});
it('extracts files to temp dir correctly', () => {
execSync(`git remote add upstream ${upstreamBareDir}`, {
cwd: projectDir,
stdio: 'pipe',
});
const { stdout, exitCode } = runFetchUpstream();
const status = parseStatus(stdout);
expect(exitCode).toBe(0);
// Check file content matches what was pushed
const pkg = JSON.parse(
fs.readFileSync(path.join(status.TEMP_DIR, 'package.json'), 'utf-8'),
);
expect(pkg.version).toBe('2.0.0');
const indexContent = fs.readFileSync(
path.join(status.TEMP_DIR, 'src/index.ts'),
'utf-8',
);
expect(indexContent).toBe('export const v = 2;');
fs.rmSync(status.TEMP_DIR, { recursive: true, force: true });
});
});

View File

@@ -0,0 +1,234 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { cleanup, createTempDir } from './test-helpers.js';
describe('run-migrations', () => {
let tmpDir: string;
let newCoreDir: string;
const scriptPath = path.resolve('scripts/run-migrations.ts');
const tsxBin = path.resolve('node_modules/.bin/tsx');
beforeEach(() => {
tmpDir = createTempDir();
newCoreDir = path.join(tmpDir, 'new-core');
fs.mkdirSync(newCoreDir, { recursive: true });
});
afterEach(() => {
cleanup(tmpDir);
});
function createMigration(version: string, code: string): void {
const migDir = path.join(newCoreDir, 'migrations', version);
fs.mkdirSync(migDir, { recursive: true });
fs.writeFileSync(path.join(migDir, 'index.ts'), code);
}
function runMigrations(
from: string,
to: string,
): { stdout: string; exitCode: number } {
try {
const stdout = execFileSync(
tsxBin,
[scriptPath, from, to, newCoreDir],
{ cwd: tmpDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 },
);
return { stdout, exitCode: 0 };
} catch (err: any) {
return { stdout: err.stdout ?? '', exitCode: err.status ?? 1 };
}
}
it('outputs empty results when no migrations directory exists', () => {
const { stdout, exitCode } = runMigrations('1.0.0', '2.0.0');
const result = JSON.parse(stdout);
expect(exitCode).toBe(0);
expect(result.migrationsRun).toBe(0);
expect(result.results).toEqual([]);
});
it('outputs empty results when migrations dir exists but is empty', () => {
fs.mkdirSync(path.join(newCoreDir, 'migrations'), { recursive: true });
const { stdout, exitCode } = runMigrations('1.0.0', '2.0.0');
const result = JSON.parse(stdout);
expect(exitCode).toBe(0);
expect(result.migrationsRun).toBe(0);
});
it('runs migrations in the correct version range', () => {
// Create a marker file when the migration runs
createMigration(
'1.1.0',
`
import fs from 'fs';
import path from 'path';
const root = process.argv[2];
fs.writeFileSync(path.join(root, 'migrated-1.1.0'), 'done');
`,
);
createMigration(
'1.2.0',
`
import fs from 'fs';
import path from 'path';
const root = process.argv[2];
fs.writeFileSync(path.join(root, 'migrated-1.2.0'), 'done');
`,
);
// This one should NOT run (outside range)
createMigration(
'2.1.0',
`
import fs from 'fs';
import path from 'path';
const root = process.argv[2];
fs.writeFileSync(path.join(root, 'migrated-2.1.0'), 'done');
`,
);
const { stdout, exitCode } = runMigrations('1.0.0', '2.0.0');
const result = JSON.parse(stdout);
expect(exitCode).toBe(0);
expect(result.migrationsRun).toBe(2);
expect(result.results[0].version).toBe('1.1.0');
expect(result.results[0].success).toBe(true);
expect(result.results[1].version).toBe('1.2.0');
expect(result.results[1].success).toBe(true);
// Verify the migrations actually ran
expect(fs.existsSync(path.join(tmpDir, 'migrated-1.1.0'))).toBe(true);
expect(fs.existsSync(path.join(tmpDir, 'migrated-1.2.0'))).toBe(true);
// 2.1.0 is outside range
expect(fs.existsSync(path.join(tmpDir, 'migrated-2.1.0'))).toBe(false);
});
it('excludes the from-version (only runs > from)', () => {
createMigration(
'1.0.0',
`
import fs from 'fs';
import path from 'path';
const root = process.argv[2];
fs.writeFileSync(path.join(root, 'migrated-1.0.0'), 'done');
`,
);
createMigration(
'1.1.0',
`
import fs from 'fs';
import path from 'path';
const root = process.argv[2];
fs.writeFileSync(path.join(root, 'migrated-1.1.0'), 'done');
`,
);
const { stdout } = runMigrations('1.0.0', '1.1.0');
const result = JSON.parse(stdout);
expect(result.migrationsRun).toBe(1);
expect(result.results[0].version).toBe('1.1.0');
// 1.0.0 should NOT have run
expect(fs.existsSync(path.join(tmpDir, 'migrated-1.0.0'))).toBe(false);
});
it('includes the to-version (<= to)', () => {
createMigration(
'2.0.0',
`
import fs from 'fs';
import path from 'path';
const root = process.argv[2];
fs.writeFileSync(path.join(root, 'migrated-2.0.0'), 'done');
`,
);
const { stdout } = runMigrations('1.0.0', '2.0.0');
const result = JSON.parse(stdout);
expect(result.migrationsRun).toBe(1);
expect(result.results[0].version).toBe('2.0.0');
expect(result.results[0].success).toBe(true);
});
it('runs migrations in semver ascending order', () => {
// Create them in non-sorted order
for (const v of ['1.3.0', '1.1.0', '1.2.0']) {
createMigration(
v,
`
import fs from 'fs';
import path from 'path';
const root = process.argv[2];
const log = path.join(root, 'migration-order.log');
const existing = fs.existsSync(log) ? fs.readFileSync(log, 'utf-8') : '';
fs.writeFileSync(log, existing + '${v}\\n');
`,
);
}
const { stdout } = runMigrations('1.0.0', '2.0.0');
const result = JSON.parse(stdout);
expect(result.migrationsRun).toBe(3);
expect(result.results.map((r: any) => r.version)).toEqual([
'1.1.0',
'1.2.0',
'1.3.0',
]);
// Verify execution order from the log file
const log = fs.readFileSync(
path.join(tmpDir, 'migration-order.log'),
'utf-8',
);
expect(log.trim()).toBe('1.1.0\n1.2.0\n1.3.0');
});
it('reports failure and exits non-zero when a migration throws', () => {
createMigration(
'1.1.0',
`throw new Error('migration failed intentionally');`,
);
const { stdout, exitCode } = runMigrations('1.0.0', '2.0.0');
const result = JSON.parse(stdout);
expect(exitCode).toBe(1);
expect(result.migrationsRun).toBe(1);
expect(result.results[0].success).toBe(false);
expect(result.results[0].error).toBeDefined();
});
it('ignores non-semver directories in migrations/', () => {
fs.mkdirSync(path.join(newCoreDir, 'migrations', 'README'), {
recursive: true,
});
fs.mkdirSync(path.join(newCoreDir, 'migrations', 'utils'), {
recursive: true,
});
createMigration(
'1.1.0',
`
import fs from 'fs';
import path from 'path';
const root = process.argv[2];
fs.writeFileSync(path.join(root, 'migrated-1.1.0'), 'done');
`,
);
const { stdout, exitCode } = runMigrations('1.0.0', '2.0.0');
const result = JSON.parse(stdout);
expect(exitCode).toBe(0);
expect(result.migrationsRun).toBe(1);
expect(result.results[0].version).toBe('1.1.0');
});
});

View File

@@ -0,0 +1,131 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { stringify } from 'yaml';
import { cleanup, createTempDir, initGitRepo, setupNanoclawDir } from './test-helpers.js';
describe('update-core.ts CLI flags', () => {
let tmpDir: string;
const scriptPath = path.resolve('scripts/update-core.ts');
const tsxBin = path.resolve('node_modules/.bin/tsx');
beforeEach(() => {
tmpDir = createTempDir();
setupNanoclawDir(tmpDir);
initGitRepo(tmpDir);
// Write state file
const statePath = path.join(tmpDir, '.nanoclaw', 'state.yaml');
fs.writeFileSync(
statePath,
stringify({
skills_system_version: '0.1.0',
core_version: '1.0.0',
applied_skills: [],
}),
);
});
afterEach(() => {
cleanup(tmpDir);
});
function createNewCore(files: Record<string, string>): string {
const dir = path.join(tmpDir, 'new-core');
fs.mkdirSync(dir, { recursive: true });
for (const [relPath, content] of Object.entries(files)) {
const fullPath = path.join(dir, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
}
return dir;
}
it('--json --preview-only outputs JSON preview without applying', () => {
const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
fs.writeFileSync(path.join(baseDir, 'src/index.ts'), 'original');
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'src/index.ts'), 'original');
const newCoreDir = createNewCore({
'src/index.ts': 'updated',
'package.json': JSON.stringify({ version: '2.0.0' }),
});
const stdout = execFileSync(
tsxBin,
[scriptPath, '--json', '--preview-only', newCoreDir],
{ cwd: tmpDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 },
);
const preview = JSON.parse(stdout);
expect(preview.currentVersion).toBe('1.0.0');
expect(preview.newVersion).toBe('2.0.0');
expect(preview.filesChanged).toContain('src/index.ts');
// File should NOT have been modified (preview only)
expect(fs.readFileSync(path.join(tmpDir, 'src/index.ts'), 'utf-8')).toBe(
'original',
);
});
it('--preview-only without --json outputs human-readable text', () => {
const newCoreDir = createNewCore({
'src/new-file.ts': 'export const x = 1;',
'package.json': JSON.stringify({ version: '2.0.0' }),
});
const stdout = execFileSync(
tsxBin,
[scriptPath, '--preview-only', newCoreDir],
{ cwd: tmpDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 },
);
expect(stdout).toContain('Update Preview');
expect(stdout).toContain('2.0.0');
// Should NOT contain JSON (it's human-readable mode)
expect(stdout).not.toContain('"currentVersion"');
});
it('--json applies and outputs JSON result', () => {
fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, 'src/index.ts'), 'original');
const newCoreDir = createNewCore({
'src/index.ts': 'original',
'package.json': JSON.stringify({ version: '2.0.0' }),
});
const stdout = execFileSync(
tsxBin,
[scriptPath, '--json', newCoreDir],
{ cwd: tmpDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 },
);
const result = JSON.parse(stdout);
expect(result.success).toBe(true);
expect(result.previousVersion).toBe('1.0.0');
expect(result.newVersion).toBe('2.0.0');
});
it('exits with error when no path provided', () => {
try {
execFileSync(tsxBin, [scriptPath], {
cwd: tmpDir,
encoding: 'utf-8',
stdio: 'pipe',
timeout: 30_000,
});
expect.unreachable('Should have exited with error');
} catch (err: any) {
expect(err.status).toBe(1);
expect(err.stderr).toContain('Usage');
}
});
});

View File

@@ -7,3 +7,7 @@ export const CUSTOM_DIR = '.nanoclaw/custom';
export const RESOLUTIONS_DIR = '.nanoclaw/resolutions';
export const SHIPPED_RESOLUTIONS_DIR = '.claude/resolutions';
export const SKILLS_SCHEMA_VERSION = '0.1.0';
// Top-level paths to include in base snapshot and upstream extraction.
// Add new entries here when new root-level directories/files need tracking.
export const BASE_INCLUDES = ['src/', 'package.json', '.env.example', 'container/'];

View File

@@ -2,14 +2,11 @@ import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { BACKUP_DIR, BASE_DIR, NANOCLAW_DIR } from './constants.js';
import { BACKUP_DIR, BASE_DIR, BASE_INCLUDES, NANOCLAW_DIR } from './constants.js';
import { isGitRepo } from './merge.js';
import { writeState } from './state.js';
import { SkillState } from './types.js';
// Top-level paths to include in base snapshot
const BASE_INCLUDES = ['src/', 'package.json', '.env.example', 'container/'];
// Directories/files to always exclude from base snapshot
const BASE_EXCLUDES = [
'node_modules',