Merge pull request #756 from glifocat/upstream/fix-register-claude-md

fix(register): create CLAUDE.md in group folder from template
This commit is contained in:
gavrielc
2026-03-25 17:34:08 +02:00
committed by GitHub
2 changed files with 238 additions and 6 deletions

View File

@@ -1,4 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, it, expect, beforeEach } from 'vitest';
import Database from 'better-sqlite3';
@@ -6,7 +9,7 @@ import Database from 'better-sqlite3';
* Tests for the register step.
*
* Verifies: parameterized SQL (no injection), file templating,
* apostrophe in names, .env updates.
* apostrophe in names, .env updates, CLAUDE.md template copy.
*/
function createTestDb(): Database.Database {
@@ -255,3 +258,207 @@ describe('file templating', () => {
expect(envContent).toContain('ASSISTANT_NAME="Nova"');
});
});
describe('CLAUDE.md template copy', () => {
let tmpDir: string;
let groupsDir: string;
// Replicates register.ts template copy + name update logic
function simulateRegister(
folder: string,
isMain: boolean,
assistantName = 'Andy',
): void {
const folderDir = path.join(groupsDir, folder);
fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true });
// Template copy — never overwrite existing (register.ts lines 119-135)
const dest = path.join(folderDir, 'CLAUDE.md');
if (!fs.existsSync(dest)) {
const templatePath = isMain
? path.join(groupsDir, 'main', 'CLAUDE.md')
: path.join(groupsDir, 'global', 'CLAUDE.md');
if (fs.existsSync(templatePath)) {
fs.copyFileSync(templatePath, dest);
}
}
// Name update across all groups (register.ts lines 140-165)
if (assistantName !== 'Andy') {
const mdFiles = fs
.readdirSync(groupsDir)
.map((d) => path.join(groupsDir, d, 'CLAUDE.md'))
.filter((f) => fs.existsSync(f));
for (const mdFile of mdFiles) {
let content = fs.readFileSync(mdFile, 'utf-8');
content = content.replace(/^# Andy$/m, `# ${assistantName}`);
content = content.replace(
/You are Andy/g,
`You are ${assistantName}`,
);
fs.writeFileSync(mdFile, content);
}
}
}
function readGroupMd(folder: string): string {
return fs.readFileSync(
path.join(groupsDir, folder, 'CLAUDE.md'),
'utf-8',
);
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-register-test-'));
groupsDir = path.join(tmpDir, 'groups');
fs.mkdirSync(path.join(groupsDir, 'main'), { recursive: true });
fs.mkdirSync(path.join(groupsDir, 'global'), { recursive: true });
fs.writeFileSync(
path.join(groupsDir, 'main', 'CLAUDE.md'),
'# Andy\n\nYou are Andy, a personal assistant.\n\n## Admin Context\n\nThis is the **main channel**.',
);
fs.writeFileSync(
path.join(groupsDir, 'global', 'CLAUDE.md'),
'# Andy\n\nYou are Andy, a personal assistant.',
);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('copies global template for non-main group', () => {
simulateRegister('telegram_dev-team', false);
const content = readGroupMd('telegram_dev-team');
expect(content).toContain('You are Andy');
expect(content).not.toContain('Admin Context');
});
it('copies main template for main group', () => {
simulateRegister('whatsapp_main', true);
expect(readGroupMd('whatsapp_main')).toContain('Admin Context');
});
it('each channel can have its own main with admin context', () => {
simulateRegister('whatsapp_main', true);
simulateRegister('telegram_main', true);
simulateRegister('slack_main', true);
simulateRegister('discord_main', true);
for (const folder of [
'whatsapp_main',
'telegram_main',
'slack_main',
'discord_main',
]) {
const content = readGroupMd(folder);
expect(content).toContain('Admin Context');
expect(content).toContain('You are Andy');
}
});
it('non-main groups across channels get global template', () => {
simulateRegister('whatsapp_main', true);
simulateRegister('telegram_friends', false);
simulateRegister('slack_engineering', false);
simulateRegister('discord_general', false);
expect(readGroupMd('whatsapp_main')).toContain('Admin Context');
for (const folder of [
'telegram_friends',
'slack_engineering',
'discord_general',
]) {
const content = readGroupMd(folder);
expect(content).toContain('You are Andy');
expect(content).not.toContain('Admin Context');
}
});
it('custom name propagates to all channels and groups', () => {
// Register multiple channels, last one sets custom name
simulateRegister('whatsapp_main', true);
simulateRegister('telegram_main', true);
simulateRegister('slack_devs', false);
// Final registration triggers name update across all
simulateRegister('discord_main', true, 'Luna');
for (const folder of [
'main',
'global',
'whatsapp_main',
'telegram_main',
'slack_devs',
'discord_main',
]) {
const content = readGroupMd(folder);
expect(content).toContain('# Luna');
expect(content).toContain('You are Luna');
expect(content).not.toContain('Andy');
}
});
it('never overwrites existing CLAUDE.md on re-registration', () => {
simulateRegister('slack_main', true);
// User customizes the file extensively (persona, workspace, rules)
const mdPath = path.join(groupsDir, 'slack_main', 'CLAUDE.md');
fs.writeFileSync(
mdPath,
'# Gambi\n\nCustom persona with workspace rules and family context.',
);
// Re-registering same folder (e.g. re-running /add-slack)
simulateRegister('slack_main', true);
const content = readGroupMd('slack_main');
expect(content).toContain('Custom persona');
expect(content).not.toContain('Admin Context');
});
it('never overwrites when non-main becomes main (isMain changes)', () => {
// User registers a family group as non-main
simulateRegister('whatsapp_casa', false);
// User extensively customizes it (PARA system, task management, etc.)
const mdPath = path.join(groupsDir, 'whatsapp_casa', 'CLAUDE.md');
fs.writeFileSync(
mdPath,
'# Casa\n\nFamily group with PARA system, task management, shopping lists.',
);
// Later, user promotes to main (no trigger required) — CLAUDE.md must be preserved
simulateRegister('whatsapp_casa', true);
const content = readGroupMd('whatsapp_casa');
expect(content).toContain('PARA system');
expect(content).not.toContain('Admin Context');
});
it('preserves custom CLAUDE.md across channels when changing main', () => {
// Real-world scenario: WhatsApp main + customized Discord research channel
simulateRegister('whatsapp_main', true);
simulateRegister('discord_main', false);
const discordPath = path.join(groupsDir, 'discord_main', 'CLAUDE.md');
fs.writeFileSync(
discordPath,
'# Gambi HQ — Research Assistant\n\nResearch workflows for Laura and Ethan.',
);
// Discord becomes main too — custom content must survive
simulateRegister('discord_main', true);
expect(readGroupMd('discord_main')).toContain('Research Assistant');
// WhatsApp main also untouched
expect(readGroupMd('whatsapp_main')).toContain('Admin Context');
});
it('handles missing templates gracefully', () => {
fs.unlinkSync(path.join(groupsDir, 'global', 'CLAUDE.md'));
fs.unlinkSync(path.join(groupsDir, 'main', 'CLAUDE.md'));
simulateRegister('discord_general', false);
expect(
fs.existsSync(path.join(groupsDir, 'discord_general', 'CLAUDE.md')),
).toBe(false);
});
});

View File

@@ -116,6 +116,30 @@ export async function run(args: string[]): Promise<void> {
recursive: true,
});
// Create CLAUDE.md in the new group folder from template if it doesn't exist.
// The agent runs with CWD=/workspace/group and loads CLAUDE.md from there.
// Never overwrite an existing CLAUDE.md — users customize these extensively
// (persona, workspace structure, communication rules, family context, etc.)
// and a stock template replacement would destroy that work.
const groupClaudeMdPath = path.join(
projectRoot,
'groups',
parsed.folder,
'CLAUDE.md',
);
if (!fs.existsSync(groupClaudeMdPath)) {
const templatePath = parsed.isMain
? path.join(projectRoot, 'groups', 'main', 'CLAUDE.md')
: path.join(projectRoot, 'groups', 'global', 'CLAUDE.md');
if (fs.existsSync(templatePath)) {
fs.copyFileSync(templatePath, groupClaudeMdPath);
logger.info(
{ file: groupClaudeMdPath, template: templatePath },
'Created CLAUDE.md from template',
);
}
}
// Update assistant name in CLAUDE.md files if different from default
let nameUpdated = false;
if (parsed.assistantName !== 'Andy') {
@@ -124,10 +148,11 @@ export async function run(args: string[]): Promise<void> {
'Updating assistant name',
);
const mdFiles = [
path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'),
path.join(projectRoot, 'groups', parsed.folder, 'CLAUDE.md'),
];
const groupsDir = path.join(projectRoot, 'groups');
const mdFiles = fs
.readdirSync(groupsDir)
.map((d) => path.join(groupsDir, d, 'CLAUDE.md'))
.filter((f) => fs.existsSync(f));
for (const mdFile of mdFiles) {
if (fs.existsSync(mdFile)) {