mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-12 18:11:51 +08:00
fix(v2): use in-tree symlink for global CLAUDE.md @import
Claude Code's @-import directive only follows paths inside the project memory tree (cwd + ancestors). Both `@/workspace/global/CLAUDE.md` and `@../global/CLAUDE.md` are silently ignored because `/workspace/global` is outside `/workspace/agent` (the cwd). The import line is parsed but the content is never loaded — validated with a sentinel passphrase test against a live container. Fix: drop a `.claude-global.md` symlink into each group's dir pointing at `/workspace/global/CLAUDE.md`. The link path is absolute on container terms (dangling on host, valid via the /workspace/global mount) and the symlink file itself is inside cwd, so Claude's @-import is happy. The group's CLAUDE.md imports via `@./.claude-global.md`. - src/group-init.ts: initGroupFilesystem now drops the symlink (idempotent, uses lstat so existsSync doesn't trip on the dangling target on the host). Default CLAUDE.md body uses `@./.claude-global.md`. - scripts/migrate-group-claude-md.ts: creates the symlink for existing groups and rewrites any broken `@/workspace/global/CLAUDE.md` or `@../global/CLAUDE.md` import line to `@./.claude-global.md`. - groups/main/CLAUDE.md: migration rewrote the import. Validated: live container with the symlinked import correctly surfaces global CLAUDE.md content (passphrase `quinoa-submarine-42` added to global, retrieved via claude -p, removed). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
@/workspace/global/CLAUDE.md
|
||||
|
||||
@./.claude-global.md
|
||||
# Main
|
||||
|
||||
You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders.
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
/**
|
||||
* One-shot migration: prepend `@/workspace/global/CLAUDE.md` to each
|
||||
* existing group's CLAUDE.md so it imports the global memory under the
|
||||
* new model where the host no longer reads global CLAUDE.md at bootstrap.
|
||||
* One-shot migration: wire each existing group up to global memory via
|
||||
* an in-tree symlink + @-import.
|
||||
*
|
||||
* - Skips entirely if `groups/global/CLAUDE.md` doesn't exist (nothing
|
||||
* to import; running the script would just add a broken @-import).
|
||||
* - Skips any group whose CLAUDE.md already references
|
||||
* `/workspace/global/CLAUDE.md` (idempotent).
|
||||
* - Skips groups with no CLAUDE.md (nothing to prepend to).
|
||||
* Claude Code's @-import only follows paths inside cwd, so a direct
|
||||
* `@/workspace/global/CLAUDE.md` or `@../global/CLAUDE.md` silently does
|
||||
* nothing (the import line is parsed but the target file is never
|
||||
* loaded into context). The working approach:
|
||||
*
|
||||
* 1. Symlink `groups/<folder>/.claude-global.md` →
|
||||
* `/workspace/global/CLAUDE.md` (container path; dangling on host,
|
||||
* valid inside the container via the /workspace/global mount).
|
||||
* 2. Have the group's CLAUDE.md import the symlink:
|
||||
* `@./.claude-global.md`.
|
||||
*
|
||||
* This script:
|
||||
* - Creates the symlink if missing.
|
||||
* - Replaces any existing broken `@/workspace/global/CLAUDE.md` or
|
||||
* `@../global/CLAUDE.md` import line with the symlink form.
|
||||
* - Prepends the symlink import if neither form is present.
|
||||
* - Skips entirely if `groups/global/CLAUDE.md` doesn't exist.
|
||||
*
|
||||
* Idempotent — safe to re-run.
|
||||
*
|
||||
* Usage: npx tsx scripts/migrate-group-claude-md.ts
|
||||
*/
|
||||
@@ -17,11 +30,14 @@ import path from 'path';
|
||||
import { GROUPS_DIR } from '../src/config.js';
|
||||
|
||||
const GLOBAL_CLAUDE_MD = path.join(GROUPS_DIR, 'global', 'CLAUDE.md');
|
||||
const IMPORT_LINE = '@/workspace/global/CLAUDE.md';
|
||||
// Must match the @-import syntax exactly — a bare path reference inside
|
||||
// instructional prose ("you can write to /workspace/global/CLAUDE.md")
|
||||
// shouldn't count as "already wired."
|
||||
const IMPORT_REGEX = /@\/workspace\/global\/CLAUDE\.md/;
|
||||
const GLOBAL_MEMORY_CONTAINER_PATH = '/workspace/global/CLAUDE.md';
|
||||
const GLOBAL_MEMORY_LINK_NAME = '.claude-global.md';
|
||||
const IMPORT_LINE = `@./${GLOBAL_MEMORY_LINK_NAME}`;
|
||||
|
||||
// Match any existing @-import that points at global/CLAUDE.md, whether
|
||||
// via absolute path, relative path, or the new symlink form.
|
||||
const EXISTING_IMPORT_REGEX =
|
||||
/^@(?:\/workspace\/global\/CLAUDE\.md|\.\.\/global\/CLAUDE\.md|\.\/\.claude-global\.md)\s*$/m;
|
||||
|
||||
if (!fs.existsSync(GLOBAL_CLAUDE_MD)) {
|
||||
console.error(`No global CLAUDE.md at ${GLOBAL_CLAUDE_MD} — nothing to migrate.`);
|
||||
@@ -37,12 +53,31 @@ const entries = fs.readdirSync(GROUPS_DIR, { withFileTypes: true });
|
||||
let updated = 0;
|
||||
let alreadyWired = 0;
|
||||
let missingClaudeMd = 0;
|
||||
let symlinksCreated = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name === 'global') continue; // not a group
|
||||
if (entry.name === 'global') continue;
|
||||
|
||||
const claudeMd = path.join(GROUPS_DIR, entry.name, 'CLAUDE.md');
|
||||
const groupDir = path.join(GROUPS_DIR, entry.name);
|
||||
|
||||
// Symlink (idempotent — skip if already present)
|
||||
const linkPath = path.join(groupDir, GLOBAL_MEMORY_LINK_NAME);
|
||||
let linkExists = false;
|
||||
try {
|
||||
fs.lstatSync(linkPath);
|
||||
linkExists = true;
|
||||
} catch {
|
||||
/* missing */
|
||||
}
|
||||
if (!linkExists) {
|
||||
fs.symlinkSync(GLOBAL_MEMORY_CONTAINER_PATH, linkPath);
|
||||
console.log(`[link] ${entry.name}: created ${GLOBAL_MEMORY_LINK_NAME}`);
|
||||
symlinksCreated++;
|
||||
}
|
||||
|
||||
// CLAUDE.md import wiring
|
||||
const claudeMd = path.join(groupDir, 'CLAUDE.md');
|
||||
if (!fs.existsSync(claudeMd)) {
|
||||
console.log(`[skip] ${entry.name}: no CLAUDE.md`);
|
||||
missingClaudeMd++;
|
||||
@@ -50,16 +85,29 @@ for (const entry of entries) {
|
||||
}
|
||||
|
||||
const body = fs.readFileSync(claudeMd, 'utf-8');
|
||||
if (IMPORT_REGEX.test(body)) {
|
||||
const match = body.match(EXISTING_IMPORT_REGEX);
|
||||
|
||||
if (match && match[0] === IMPORT_LINE) {
|
||||
console.log(`[wired] ${entry.name}: already imports ${IMPORT_LINE}`);
|
||||
alreadyWired++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const newBody = `${IMPORT_LINE}\n\n${body}`;
|
||||
let newBody: string;
|
||||
if (match) {
|
||||
// Replace the broken import with the working form
|
||||
newBody = body.replace(EXISTING_IMPORT_REGEX, IMPORT_LINE);
|
||||
console.log(`[fix] ${entry.name}: rewrote ${match[0]} → ${IMPORT_LINE}`);
|
||||
} else {
|
||||
// Prepend fresh
|
||||
newBody = `${IMPORT_LINE}\n\n${body}`;
|
||||
console.log(`[ok] ${entry.name}: prepended ${IMPORT_LINE}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(claudeMd, newBody);
|
||||
console.log(`[ok] ${entry.name}: prepended import`);
|
||||
updated++;
|
||||
}
|
||||
|
||||
console.log(`\nDone. updated=${updated} alreadyWired=${alreadyWired} missingClaudeMd=${missingClaudeMd}`);
|
||||
console.log(
|
||||
`\nDone. updated=${updated} alreadyWired=${alreadyWired} missingClaudeMd=${missingClaudeMd} symlinksCreated=${symlinksCreated}`,
|
||||
);
|
||||
|
||||
+28
-1
@@ -5,7 +5,17 @@ import { DATA_DIR, GROUPS_DIR } from './config.js';
|
||||
import { log } from './log.js';
|
||||
import type { AgentGroup } from './types.js';
|
||||
|
||||
const GLOBAL_CLAUDE_IMPORT = '@/workspace/global/CLAUDE.md';
|
||||
// Container path where groups/global is mounted. The symlink we drop
|
||||
// into each group's dir resolves to this target inside the container.
|
||||
// It's a dangling symlink on the host — that's fine, host tools don't
|
||||
// follow it and the container mount makes it valid at read time.
|
||||
const GLOBAL_MEMORY_CONTAINER_PATH = '/workspace/global/CLAUDE.md';
|
||||
|
||||
// Symlink name inside the group's dir. Claude Code's @-import only
|
||||
// follows paths inside cwd, so we can't reference /workspace/global
|
||||
// directly — we symlink into the group dir and import the symlink.
|
||||
export const GLOBAL_MEMORY_LINK_NAME = '.claude-global.md';
|
||||
export const GLOBAL_CLAUDE_IMPORT = `@./${GLOBAL_MEMORY_LINK_NAME}`;
|
||||
|
||||
const DEFAULT_SETTINGS_JSON =
|
||||
JSON.stringify(
|
||||
@@ -41,6 +51,23 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s
|
||||
initialized.push('groupDir');
|
||||
}
|
||||
|
||||
// groups/<folder>/.claude-global.md — symlink into the group dir so
|
||||
// Claude Code's @-import can follow it. Uses lstat to avoid tripping
|
||||
// existsSync on a dangling symlink (target only resolves inside the
|
||||
// container).
|
||||
const globalLinkPath = path.join(groupDir, GLOBAL_MEMORY_LINK_NAME);
|
||||
let linkExists = false;
|
||||
try {
|
||||
fs.lstatSync(globalLinkPath);
|
||||
linkExists = true;
|
||||
} catch {
|
||||
/* missing — recreate */
|
||||
}
|
||||
if (!linkExists) {
|
||||
fs.symlinkSync(GLOBAL_MEMORY_CONTAINER_PATH, globalLinkPath);
|
||||
initialized.push('.claude-global.md');
|
||||
}
|
||||
|
||||
// groups/<folder>/CLAUDE.md — written once, then owned by the group
|
||||
const claudeMdFile = path.join(groupDir, 'CLAUDE.md');
|
||||
if (!fs.existsSync(claudeMdFile)) {
|
||||
|
||||
Reference in New Issue
Block a user