Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 3cf6615

Browse files
MattBroclaudepauldambra
authored
fix: expand @-imports when syncing personalization from CLAUDE.md (#3405)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Paul D'Ambra <paul@posthog.com>
1 parent ac76f5f commit 3cf6615

2 files changed

Lines changed: 491 additions & 3 deletions

File tree

packages/workspace-server/src/services/os/os.test.ts

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
44

55
const mockReadFile = vi.hoisted(() => vi.fn());
66
const mockStat = vi.hoisted(() => vi.fn());
7+
const mockRealpath = vi.hoisted(() => vi.fn());
78

89
vi.mock("node:fs", () => {
910
const promises = {
1011
readFile: mockReadFile,
1112
stat: mockStat,
13+
realpath: mockRealpath,
1214
access: vi.fn(),
1315
writeFile: vi.fn(),
1416
unlink: vi.fn(),
@@ -54,6 +56,7 @@ function createService() {
5456

5557
beforeEach(() => {
5658
vi.clearAllMocks();
59+
mockRealpath.mockImplementation(async (p: string) => p);
5760
});
5861

5962
describe("OsService.showMessageBox", () => {
@@ -253,6 +256,276 @@ describe("OsService.getUserAgentInstructions", () => {
253256
});
254257
});
255258

259+
describe("OsService.getUserAgentInstructions @-import expansion", () => {
260+
const home = os.homedir();
261+
const claudeDir = path.join(home, ".claude");
262+
const claudePath = path.join(claudeDir, "CLAUDE.md");
263+
const aPath = path.join(claudeDir, "a.md");
264+
const bPath = path.join(claudeDir, "b.md");
265+
const engineeringPath = path.join(claudeDir, "engineering.md");
266+
267+
function givenFiles(files: Record<string, string>) {
268+
mockReadFile.mockImplementation(async (filePath: string) => {
269+
if (filePath in files) return files[filePath];
270+
throw new Error("ENOENT");
271+
});
272+
}
273+
274+
it.each([
275+
{
276+
label: "leaves files without imports untouched",
277+
files: { [claudePath]: "just plain rules\nno imports here" },
278+
expected: "just plain rules\nno imports here",
279+
},
280+
{
281+
label: "inlines a single relative import",
282+
files: {
283+
[claudePath]: "top rules\n@./engineering.md",
284+
[engineeringPath]: "engineering rules",
285+
},
286+
expected: "top rules\nengineering rules",
287+
},
288+
{
289+
label: "recursively inlines nested imports",
290+
files: {
291+
[claudePath]: "@./a.md",
292+
[aPath]: "A\n@./b.md",
293+
[bPath]: "B",
294+
},
295+
expected: "A\nB",
296+
},
297+
{
298+
label: "leaves the reference literal on a cycle",
299+
files: {
300+
[claudePath]: "@./a.md",
301+
[aPath]: "A\n@./a.md",
302+
},
303+
expected: "A\n@./a.md",
304+
},
305+
{
306+
label: "leaves a missing import as its literal reference",
307+
files: { [claudePath]: "top\n@./missing.md" },
308+
expected: "top\n@./missing.md",
309+
},
310+
{
311+
label: "does not expand imports inside inline code spans",
312+
files: {
313+
[claudePath]: "mention `@./engineering.md` literally",
314+
[engineeringPath]: "engineering rules",
315+
},
316+
expected: "mention `@./engineering.md` literally",
317+
},
318+
{
319+
label: "does not expand imports inside fenced code blocks",
320+
files: {
321+
[claudePath]: "```\n@./engineering.md\n```",
322+
[engineeringPath]: "engineering rules",
323+
},
324+
expected: "```\n@./engineering.md\n```",
325+
},
326+
{
327+
label: "a shorter fence line does not close a longer fence",
328+
files: {
329+
[claudePath]: "````\n```\n@./engineering.md\n```\n````",
330+
[engineeringPath]: "engineering rules",
331+
},
332+
expected: "````\n```\n@./engineering.md\n```\n````",
333+
},
334+
{
335+
label: "a longer fence line closes a shorter fence",
336+
files: {
337+
[claudePath]: "```\n@./engineering.md\n````\n@./engineering.md",
338+
[engineeringPath]: "engineering rules",
339+
},
340+
expected: "```\n@./engineering.md\n````\nengineering rules",
341+
},
342+
{
343+
label: "a backtick fence line does not close a tilde fence",
344+
files: {
345+
[claudePath]: "~~~\n```\n@./engineering.md\n~~~",
346+
[engineeringPath]: "engineering rules",
347+
},
348+
expected: "~~~\n```\n@./engineering.md\n~~~",
349+
},
350+
{
351+
label: "a tilde fence line does not close a backtick fence",
352+
files: {
353+
[claudePath]: "```\n~~~\n@./engineering.md\n```",
354+
[engineeringPath]: "engineering rules",
355+
},
356+
expected: "```\n~~~\n@./engineering.md\n```",
357+
},
358+
{
359+
label: "expands after a normally closed fence",
360+
files: {
361+
[claudePath]: "```\n@./engineering.md\n```\n@./engineering.md",
362+
[engineeringPath]: "engineering rules",
363+
},
364+
expected: "```\n@./engineering.md\n```\nengineering rules",
365+
},
366+
{
367+
label: "a fence line with an info string is not a closer",
368+
files: {
369+
[claudePath]: "```\n@./engineering.md\n``` js\n@./engineering.md\n```",
370+
[engineeringPath]: "engineering rules",
371+
},
372+
expected: "```\n@./engineering.md\n``` js\n@./engineering.md\n```",
373+
},
374+
{
375+
label:
376+
"a backtick run with backticks in its info string is a span, not a fence",
377+
files: {
378+
[claudePath]: "```@x```\n@./engineering.md",
379+
[engineeringPath]: "engineering rules",
380+
},
381+
expected: "```@x```\nengineering rules",
382+
},
383+
{
384+
label: "keeps an indented code line after a blank line literal",
385+
files: {
386+
[claudePath]: "intro\n\n @./engineering.md",
387+
[engineeringPath]: "engineering rules",
388+
},
389+
expected: "intro\n\n @./engineering.md",
390+
},
391+
{
392+
label: "keeps a tab-indented code line after a blank line literal",
393+
files: {
394+
[claudePath]: "intro\n\n\t@./engineering.md",
395+
[engineeringPath]: "engineering rules",
396+
},
397+
expected: "intro\n\n\t@./engineering.md",
398+
},
399+
{
400+
label: "expands an indented list continuation without a preceding blank",
401+
files: {
402+
[claudePath]: "- see:\n @./engineering.md",
403+
[engineeringPath]: "engineering rules",
404+
},
405+
expected: "- see:\n engineering rules",
406+
},
407+
{
408+
label:
409+
"an indented code block survives internal blanks and ends on dedent",
410+
files: {
411+
[claudePath]:
412+
"intro\n\n @./engineering.md\n\nafter @./engineering.md",
413+
[engineeringPath]: "engineering rules",
414+
},
415+
expected: "intro\n\n @./engineering.md\n\nafter engineering rules",
416+
},
417+
{
418+
label: "a three-space indent is not an indented code block",
419+
files: {
420+
[claudePath]: "intro\n\n @./engineering.md",
421+
[engineeringPath]: "engineering rules",
422+
},
423+
expected: "intro\n\n engineering rules",
424+
},
425+
{
426+
label: "a four-space-indented fence line is indented code, not a fence",
427+
files: {
428+
[claudePath]: "intro\n\n ````\n@./engineering.md",
429+
[engineeringPath]: "engineering rules",
430+
},
431+
expected: "intro\n\n ````\nengineering rules",
432+
},
433+
{
434+
label: "does not expand imports inside double-backtick code spans",
435+
files: {
436+
[claudePath]: "see `` @./engineering.md `` then @./engineering.md",
437+
[engineeringPath]: "engineering rules",
438+
},
439+
expected: "see `` @./engineering.md `` then engineering rules",
440+
},
441+
{
442+
label: "a double-backtick span may contain a single backtick",
443+
files: {
444+
[claudePath]: "`` a`b `` @./engineering.md",
445+
[engineeringPath]: "engineering rules",
446+
},
447+
expected: "`` a`b `` engineering rules",
448+
},
449+
{
450+
label: "expands after an unmatched backtick run",
451+
files: {
452+
[claudePath]: "a lone ` backtick then @./engineering.md",
453+
[engineeringPath]: "engineering rules",
454+
},
455+
expected: "a lone ` backtick then engineering rules",
456+
},
457+
{
458+
label: "expands outside a span at the start of a line",
459+
files: {
460+
[claudePath]: "`@./engineering.md` and @./engineering.md",
461+
[engineeringPath]: "engineering rules",
462+
},
463+
expected: "`@./engineering.md` and engineering rules",
464+
},
465+
{
466+
label: "expands between two code spans",
467+
files: {
468+
[claudePath]: "`a` @./engineering.md `b`",
469+
[engineeringPath]: "engineering rules",
470+
},
471+
expected: "`a` engineering rules `b`",
472+
},
473+
])("$label", async ({ files, expected }) => {
474+
const { service } = createService();
475+
givenFiles(files);
476+
477+
const result = await service.getUserAgentInstructions();
478+
expect(result?.content).toBe(expected);
479+
expect(result?.truncated).toBe(false);
480+
});
481+
482+
it("stops following imports past the max depth", async () => {
483+
const { service } = createService();
484+
const chain = path.join(claudeDir, "d5.md");
485+
givenFiles({
486+
[claudePath]: "@./d1.md",
487+
[path.join(claudeDir, "d1.md")]: "1\n@./d2.md",
488+
[path.join(claudeDir, "d2.md")]: "2\n@./d3.md",
489+
[path.join(claudeDir, "d3.md")]: "3\n@./d4.md",
490+
[path.join(claudeDir, "d4.md")]: "4\n@./d5.md",
491+
[chain]: "5",
492+
});
493+
494+
const result = await service.getUserAgentInstructions();
495+
expect(result?.content).toBe("1\n2\n3\n4\n@./d5.md");
496+
});
497+
498+
it("resolves relative imports against a symlinked file's real directory", async () => {
499+
const { service } = createService();
500+
const realDir = path.join(path.sep, "dotfiles", "claude");
501+
const realClaudePath = path.join(realDir, "CLAUDE.md");
502+
const realEngineeringPath = path.join(realDir, "engineering.md");
503+
504+
mockRealpath.mockImplementation(async (p: string) =>
505+
p === claudePath ? realClaudePath : p,
506+
);
507+
givenFiles({
508+
[claudePath]: "root\n@./engineering.md",
509+
[realEngineeringPath]: "engineering rules from dotfiles",
510+
});
511+
512+
const result = await service.getUserAgentInstructions();
513+
expect(result?.content).toBe("root\nengineering rules from dotfiles");
514+
});
515+
516+
it("applies the length cap after expansion", async () => {
517+
const { service } = createService();
518+
givenFiles({
519+
[claudePath]: "@./big.md",
520+
[path.join(claudeDir, "big.md")]: "x".repeat(25_000),
521+
});
522+
523+
const result = await service.getUserAgentInstructions();
524+
expect(result?.content).toHaveLength(20_000);
525+
expect(result?.truncated).toBe(true);
526+
});
527+
});
528+
256529
describe("OsService.getClaudePermissions", () => {
257530
it("returns the allow and deny arrays from the settings file", async () => {
258531
const { service } = createService();

0 commit comments

Comments
 (0)