@rozek/nanoclaw 0.0.24 → 0.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,7 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, describe, it, expect, beforeEach } from 'vitest';
2
5
 
3
6
  import Database from 'better-sqlite3';
4
7
 
@@ -6,7 +9,7 @@ import Database from 'better-sqlite3';
6
9
  * Tests for the register step.
7
10
  *
8
11
  * Verifies: parameterized SQL (no injection), file templating,
9
- * apostrophe in names, .env updates.
12
+ * apostrophe in names, .env updates, CLAUDE.md template copy.
10
13
  */
11
14
 
12
15
  function createTestDb(): Database.Database {
@@ -255,3 +258,207 @@ describe('file templating', () => {
255
258
  expect(envContent).toContain('ASSISTANT_NAME="Nova"');
256
259
  });
257
260
  });
261
+
262
+ describe('CLAUDE.md template copy', () => {
263
+ let tmpDir: string;
264
+ let groupsDir: string;
265
+
266
+ // Replicates register.ts template copy + name update logic
267
+ function simulateRegister(
268
+ folder: string,
269
+ isMain: boolean,
270
+ assistantName = 'Andy',
271
+ ): void {
272
+ const folderDir = path.join(groupsDir, folder);
273
+ fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true });
274
+
275
+ // Template copy — never overwrite existing (register.ts lines 119-135)
276
+ const dest = path.join(folderDir, 'CLAUDE.md');
277
+ if (!fs.existsSync(dest)) {
278
+ const templatePath = isMain
279
+ ? path.join(groupsDir, 'main', 'CLAUDE.md')
280
+ : path.join(groupsDir, 'global', 'CLAUDE.md');
281
+ if (fs.existsSync(templatePath)) {
282
+ fs.copyFileSync(templatePath, dest);
283
+ }
284
+ }
285
+
286
+ // Name update across all groups (register.ts lines 140-165)
287
+ if (assistantName !== 'Andy') {
288
+ const mdFiles = fs
289
+ .readdirSync(groupsDir)
290
+ .map((d) => path.join(groupsDir, d, 'CLAUDE.md'))
291
+ .filter((f) => fs.existsSync(f));
292
+
293
+ for (const mdFile of mdFiles) {
294
+ let content = fs.readFileSync(mdFile, 'utf-8');
295
+ content = content.replace(/^# Andy$/m, `# ${assistantName}`);
296
+ content = content.replace(
297
+ /You are Andy/g,
298
+ `You are ${assistantName}`,
299
+ );
300
+ fs.writeFileSync(mdFile, content);
301
+ }
302
+ }
303
+ }
304
+
305
+ function readGroupMd(folder: string): string {
306
+ return fs.readFileSync(
307
+ path.join(groupsDir, folder, 'CLAUDE.md'),
308
+ 'utf-8',
309
+ );
310
+ }
311
+
312
+ beforeEach(() => {
313
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-register-test-'));
314
+ groupsDir = path.join(tmpDir, 'groups');
315
+ fs.mkdirSync(path.join(groupsDir, 'main'), { recursive: true });
316
+ fs.mkdirSync(path.join(groupsDir, 'global'), { recursive: true });
317
+ fs.writeFileSync(
318
+ path.join(groupsDir, 'main', 'CLAUDE.md'),
319
+ '# Andy\n\nYou are Andy, a personal assistant.\n\n## Admin Context\n\nThis is the **main channel**.',
320
+ );
321
+ fs.writeFileSync(
322
+ path.join(groupsDir, 'global', 'CLAUDE.md'),
323
+ '# Andy\n\nYou are Andy, a personal assistant.',
324
+ );
325
+ });
326
+
327
+ afterEach(() => {
328
+ fs.rmSync(tmpDir, { recursive: true, force: true });
329
+ });
330
+
331
+ it('copies global template for non-main group', () => {
332
+ simulateRegister('telegram_dev-team', false);
333
+
334
+ const content = readGroupMd('telegram_dev-team');
335
+ expect(content).toContain('You are Andy');
336
+ expect(content).not.toContain('Admin Context');
337
+ });
338
+
339
+ it('copies main template for main group', () => {
340
+ simulateRegister('whatsapp_main', true);
341
+
342
+ expect(readGroupMd('whatsapp_main')).toContain('Admin Context');
343
+ });
344
+
345
+ it('each channel can have its own main with admin context', () => {
346
+ simulateRegister('whatsapp_main', true);
347
+ simulateRegister('telegram_main', true);
348
+ simulateRegister('slack_main', true);
349
+ simulateRegister('discord_main', true);
350
+
351
+ for (const folder of [
352
+ 'whatsapp_main',
353
+ 'telegram_main',
354
+ 'slack_main',
355
+ 'discord_main',
356
+ ]) {
357
+ const content = readGroupMd(folder);
358
+ expect(content).toContain('Admin Context');
359
+ expect(content).toContain('You are Andy');
360
+ }
361
+ });
362
+
363
+ it('non-main groups across channels get global template', () => {
364
+ simulateRegister('whatsapp_main', true);
365
+ simulateRegister('telegram_friends', false);
366
+ simulateRegister('slack_engineering', false);
367
+ simulateRegister('discord_general', false);
368
+
369
+ expect(readGroupMd('whatsapp_main')).toContain('Admin Context');
370
+ for (const folder of [
371
+ 'telegram_friends',
372
+ 'slack_engineering',
373
+ 'discord_general',
374
+ ]) {
375
+ const content = readGroupMd(folder);
376
+ expect(content).toContain('You are Andy');
377
+ expect(content).not.toContain('Admin Context');
378
+ }
379
+ });
380
+
381
+ it('custom name propagates to all channels and groups', () => {
382
+ // Register multiple channels, last one sets custom name
383
+ simulateRegister('whatsapp_main', true);
384
+ simulateRegister('telegram_main', true);
385
+ simulateRegister('slack_devs', false);
386
+ // Final registration triggers name update across all
387
+ simulateRegister('discord_main', true, 'Luna');
388
+
389
+ for (const folder of [
390
+ 'main',
391
+ 'global',
392
+ 'whatsapp_main',
393
+ 'telegram_main',
394
+ 'slack_devs',
395
+ 'discord_main',
396
+ ]) {
397
+ const content = readGroupMd(folder);
398
+ expect(content).toContain('# Luna');
399
+ expect(content).toContain('You are Luna');
400
+ expect(content).not.toContain('Andy');
401
+ }
402
+ });
403
+
404
+ it('never overwrites existing CLAUDE.md on re-registration', () => {
405
+ simulateRegister('slack_main', true);
406
+ // User customizes the file extensively (persona, workspace, rules)
407
+ const mdPath = path.join(groupsDir, 'slack_main', 'CLAUDE.md');
408
+ fs.writeFileSync(
409
+ mdPath,
410
+ '# Gambi\n\nCustom persona with workspace rules and family context.',
411
+ );
412
+ // Re-registering same folder (e.g. re-running /add-slack)
413
+ simulateRegister('slack_main', true);
414
+
415
+ const content = readGroupMd('slack_main');
416
+ expect(content).toContain('Custom persona');
417
+ expect(content).not.toContain('Admin Context');
418
+ });
419
+
420
+ it('never overwrites when non-main becomes main (isMain changes)', () => {
421
+ // User registers a family group as non-main
422
+ simulateRegister('whatsapp_casa', false);
423
+ // User extensively customizes it (PARA system, task management, etc.)
424
+ const mdPath = path.join(groupsDir, 'whatsapp_casa', 'CLAUDE.md');
425
+ fs.writeFileSync(
426
+ mdPath,
427
+ '# Casa\n\nFamily group with PARA system, task management, shopping lists.',
428
+ );
429
+ // Later, user promotes to main (no trigger required) — CLAUDE.md must be preserved
430
+ simulateRegister('whatsapp_casa', true);
431
+
432
+ const content = readGroupMd('whatsapp_casa');
433
+ expect(content).toContain('PARA system');
434
+ expect(content).not.toContain('Admin Context');
435
+ });
436
+
437
+ it('preserves custom CLAUDE.md across channels when changing main', () => {
438
+ // Real-world scenario: WhatsApp main + customized Discord research channel
439
+ simulateRegister('whatsapp_main', true);
440
+ simulateRegister('discord_main', false);
441
+ const discordPath = path.join(groupsDir, 'discord_main', 'CLAUDE.md');
442
+ fs.writeFileSync(
443
+ discordPath,
444
+ '# Gambi HQ — Research Assistant\n\nResearch workflows for Laura and Ethan.',
445
+ );
446
+
447
+ // Discord becomes main too — custom content must survive
448
+ simulateRegister('discord_main', true);
449
+ expect(readGroupMd('discord_main')).toContain('Research Assistant');
450
+ // WhatsApp main also untouched
451
+ expect(readGroupMd('whatsapp_main')).toContain('Admin Context');
452
+ });
453
+
454
+ it('handles missing templates gracefully', () => {
455
+ fs.unlinkSync(path.join(groupsDir, 'global', 'CLAUDE.md'));
456
+ fs.unlinkSync(path.join(groupsDir, 'main', 'CLAUDE.md'));
457
+
458
+ simulateRegister('discord_general', false);
459
+
460
+ expect(
461
+ fs.existsSync(path.join(groupsDir, 'discord_general', 'CLAUDE.md')),
462
+ ).toBe(false);
463
+ });
464
+ });
package/setup/register.ts CHANGED
@@ -116,6 +116,30 @@ export async function run(args: string[]): Promise<void> {
116
116
  recursive: true,
117
117
  });
118
118
 
119
+ // Create CLAUDE.md in the new group folder from template if it doesn't exist.
120
+ // The agent runs with CWD=/workspace/group and loads CLAUDE.md from there.
121
+ // Never overwrite an existing CLAUDE.md — users customize these extensively
122
+ // (persona, workspace structure, communication rules, family context, etc.)
123
+ // and a stock template replacement would destroy that work.
124
+ const groupClaudeMdPath = path.join(
125
+ projectRoot,
126
+ 'groups',
127
+ parsed.folder,
128
+ 'CLAUDE.md',
129
+ );
130
+ if (!fs.existsSync(groupClaudeMdPath)) {
131
+ const templatePath = parsed.isMain
132
+ ? path.join(projectRoot, 'groups', 'main', 'CLAUDE.md')
133
+ : path.join(projectRoot, 'groups', 'global', 'CLAUDE.md');
134
+ if (fs.existsSync(templatePath)) {
135
+ fs.copyFileSync(templatePath, groupClaudeMdPath);
136
+ logger.info(
137
+ { file: groupClaudeMdPath, template: templatePath },
138
+ 'Created CLAUDE.md from template',
139
+ );
140
+ }
141
+ }
142
+
119
143
  // Update assistant name in CLAUDE.md files if different from default
120
144
  let nameUpdated = false;
121
145
  if (parsed.assistantName !== 'Andy') {
@@ -124,10 +148,11 @@ export async function run(args: string[]): Promise<void> {
124
148
  'Updating assistant name',
125
149
  );
126
150
 
127
- const mdFiles = [
128
- path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'),
129
- path.join(projectRoot, 'groups', parsed.folder, 'CLAUDE.md'),
130
- ];
151
+ const groupsDir = path.join(projectRoot, 'groups');
152
+ const mdFiles = fs
153
+ .readdirSync(groupsDir)
154
+ .map((d) => path.join(groupsDir, d, 'CLAUDE.md'))
155
+ .filter((f) => fs.existsSync(f));
131
156
 
132
157
  for (const mdFile of mdFiles) {
133
158
  if (fs.existsSync(mdFile)) {