From b3c6aeb8d67d9491a5a1198517dab5c881e1228c Mon Sep 17 00:00:00 2001 From: Matt Brooker Date: Mon, 13 Jul 2026 16:19:56 -0400 Subject: [PATCH 1/2] fix: expand @-imports when syncing personalization from CLAUDE.md Co-Authored-By: Claude Opus 4.8 --- .../src/services/os/os.test.ts | 126 ++++++++++++++++ .../workspace-server/src/services/os/os.ts | 141 +++++++++++++++++- 2 files changed, 264 insertions(+), 3 deletions(-) diff --git a/packages/workspace-server/src/services/os/os.test.ts b/packages/workspace-server/src/services/os/os.test.ts index 59b7b56626..f9b86dc279 100644 --- a/packages/workspace-server/src/services/os/os.test.ts +++ b/packages/workspace-server/src/services/os/os.test.ts @@ -4,11 +4,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockReadFile = vi.hoisted(() => vi.fn()); const mockStat = vi.hoisted(() => vi.fn()); +const mockRealpath = vi.hoisted(() => vi.fn()); vi.mock("node:fs", () => { const promises = { readFile: mockReadFile, stat: mockStat, + realpath: mockRealpath, access: vi.fn(), writeFile: vi.fn(), unlink: vi.fn(), @@ -54,6 +56,7 @@ function createService() { beforeEach(() => { vi.clearAllMocks(); + mockRealpath.mockImplementation(async (p: string) => p); }); describe("OsService.showMessageBox", () => { @@ -253,6 +256,129 @@ describe("OsService.getUserAgentInstructions", () => { }); }); +describe("OsService.getUserAgentInstructions @-import expansion", () => { + const home = os.homedir(); + const claudeDir = path.join(home, ".claude"); + const claudePath = path.join(claudeDir, "CLAUDE.md"); + const aPath = path.join(claudeDir, "a.md"); + const bPath = path.join(claudeDir, "b.md"); + const engineeringPath = path.join(claudeDir, "engineering.md"); + + function givenFiles(files: Record) { + mockReadFile.mockImplementation(async (filePath: string) => { + if (filePath in files) return files[filePath]; + throw new Error("ENOENT"); + }); + } + + it.each([ + { + label: "leaves files without imports untouched", + files: { [claudePath]: "just plain rules\nno imports here" }, + expected: "just plain rules\nno imports here", + }, + { + label: "inlines a single relative import", + files: { + [claudePath]: "top rules\n@./engineering.md", + [engineeringPath]: "engineering rules", + }, + expected: "top rules\nengineering rules", + }, + { + label: "recursively inlines nested imports", + files: { + [claudePath]: "@./a.md", + [aPath]: "A\n@./b.md", + [bPath]: "B", + }, + expected: "A\nB", + }, + { + label: "leaves the reference literal on a cycle", + files: { + [claudePath]: "@./a.md", + [aPath]: "A\n@./a.md", + }, + expected: "A\n@./a.md", + }, + { + label: "leaves a missing import as its literal reference", + files: { [claudePath]: "top\n@./missing.md" }, + expected: "top\n@./missing.md", + }, + { + label: "does not expand imports inside inline code spans", + files: { + [claudePath]: "mention `@./engineering.md` literally", + [engineeringPath]: "engineering rules", + }, + expected: "mention `@./engineering.md` literally", + }, + { + label: "does not expand imports inside fenced code blocks", + files: { + [claudePath]: "```\n@./engineering.md\n```", + [engineeringPath]: "engineering rules", + }, + expected: "```\n@./engineering.md\n```", + }, + ])("$label", async ({ files, expected }) => { + const { service } = createService(); + givenFiles(files); + + const result = await service.getUserAgentInstructions(); + expect(result?.content).toBe(expected); + expect(result?.truncated).toBe(false); + }); + + it("stops following imports past the max depth", async () => { + const { service } = createService(); + const chain = path.join(claudeDir, "d5.md"); + givenFiles({ + [claudePath]: "@./d1.md", + [path.join(claudeDir, "d1.md")]: "1\n@./d2.md", + [path.join(claudeDir, "d2.md")]: "2\n@./d3.md", + [path.join(claudeDir, "d3.md")]: "3\n@./d4.md", + [path.join(claudeDir, "d4.md")]: "4\n@./d5.md", + [chain]: "5", + }); + + const result = await service.getUserAgentInstructions(); + expect(result?.content).toBe("1\n2\n3\n4\n@./d5.md"); + }); + + it("resolves relative imports against a symlinked file's real directory", async () => { + const { service } = createService(); + const realDir = path.join(path.sep, "dotfiles", "claude"); + const realClaudePath = path.join(realDir, "CLAUDE.md"); + const realEngineeringPath = path.join(realDir, "engineering.md"); + + mockRealpath.mockImplementation(async (p: string) => + p === claudePath ? realClaudePath : p, + ); + givenFiles({ + [claudePath]: "root\n@./engineering.md", + [realEngineeringPath]: "engineering rules from dotfiles", + }); + + const result = await service.getUserAgentInstructions(); + expect(result?.content).toBe("root\nengineering rules from dotfiles"); + }); + + it("applies the length cap after expansion", async () => { + const { service } = createService(); + givenFiles({ + [claudePath]: "@./big.md", + [path.join(claudeDir, "big.md")]: "x".repeat(25_000), + }); + + const result = await service.getUserAgentInstructions(); + expect(result?.content).toHaveLength(20_000); + expect(result?.truncated).toBe(true); + }); +}); + describe("OsService.getClaudePermissions", () => { it("returns the allow and deny arrays from the settings file", async () => { const { service } = createService(); diff --git a/packages/workspace-server/src/services/os/os.ts b/packages/workspace-server/src/services/os/os.ts index 997ffee993..0ff2ff09d2 100644 --- a/packages/workspace-server/src/services/os/os.ts +++ b/packages/workspace-server/src/services/os/os.ts @@ -59,6 +59,12 @@ const USER_AGENT_INSTRUCTIONS_CANDIDATES: ReadonlyArray<[string, string]> = [ [".claude", "CLAUDE.md"], ]; +// Claude Code follows `@path` imports up to four hops deep; we match that so a +// stub CLAUDE.md that only `@`-imports its real rules still syncs those rules. +const USER_AGENT_INSTRUCTIONS_MAX_IMPORT_DEPTH = 4; +const AGENT_IMPORT_PATTERN_SOURCE = "(^|\\s)@(\\S+)"; +const FENCE_PATTERN = /^\s*(`{3,}|~{3,})/; + @injectable() export class OsService { constructor( @@ -108,19 +114,148 @@ export class OsService { continue; } if (!content.trim()) continue; - const truncated = content.length > USER_AGENT_INSTRUCTIONS_MAX_LENGTH; + + const realPath = await this.realpathOrSelf(filePath); + const expanded = await this.expandAgentImports( + content, + path.dirname(realPath), + 1, + new Set([realPath]), + ); + const truncated = expanded.length > USER_AGENT_INSTRUCTIONS_MAX_LENGTH; return { path: filePath, displayPath: `~/${dir}/${file}`, content: truncated - ? content.slice(0, USER_AGENT_INSTRUCTIONS_MAX_LENGTH) - : content, + ? expanded.slice(0, USER_AGENT_INSTRUCTIONS_MAX_LENGTH) + : expanded, truncated, }; } return null; } + private async realpathOrSelf(filePath: string): Promise { + try { + return await fsPromises.realpath(filePath); + } catch { + return filePath; + } + } + + private async expandAgentImports( + content: string, + baseDir: string, + depth: number, + visited: Set, + ): Promise { + if (depth > USER_AGENT_INSTRUCTIONS_MAX_IMPORT_DEPTH) return content; + + const lines = content.split("\n"); + const expandedLines: string[] = []; + let fenceMarker: string | null = null; + + for (const line of lines) { + const fence = line.match(FENCE_PATTERN); + if (fence) { + const marker = fence[1][0]; + if (fenceMarker === null) fenceMarker = marker; + else if (marker === fenceMarker) fenceMarker = null; + expandedLines.push(line); + continue; + } + if (fenceMarker !== null) { + expandedLines.push(line); + continue; + } + expandedLines.push( + await this.expandImportsInLine(line, baseDir, depth, visited), + ); + } + + return expandedLines.join("\n"); + } + + private async expandImportsInLine( + line: string, + baseDir: string, + depth: number, + visited: Set, + ): Promise { + // Odd-indexed segments sit inside single-backtick code spans, where + // Claude Code treats `@path` as literal text rather than an import. + const segments = line.split("`"); + const rebuilt = await Promise.all( + segments.map((segment, index) => + index % 2 === 0 + ? this.expandImportsInSegment(segment, baseDir, depth, visited) + : Promise.resolve(segment), + ), + ); + return rebuilt.join("`"); + } + + private async expandImportsInSegment( + segment: string, + baseDir: string, + depth: number, + visited: Set, + ): Promise { + const pattern = new RegExp(AGENT_IMPORT_PATTERN_SOURCE, "g"); + let result = ""; + let lastIndex = 0; + for (const match of segment.matchAll(pattern)) { + const [full, lead, importPath] = match; + const matchIndex = match.index ?? 0; + result += segment.slice(lastIndex, matchIndex) + lead; + const imported = await this.resolveAgentImport( + importPath, + baseDir, + depth, + visited, + ); + result += imported ?? `@${importPath}`; + lastIndex = matchIndex + full.length; + } + result += segment.slice(lastIndex); + return result; + } + + private async resolveAgentImport( + importPath: string, + baseDir: string, + depth: number, + visited: Set, + ): Promise { + const resolved = importPath.startsWith("~") + ? path.join(os.homedir(), importPath.slice(1)) + : path.resolve(baseDir, importPath); + + let realPath: string; + try { + realPath = await fsPromises.realpath(resolved); + } catch { + return null; + } + if (visited.has(realPath)) return null; + + let imported: string; + try { + imported = await fsPromises.readFile(realPath, "utf-8"); + } catch { + return null; + } + + const nextVisited = new Set(visited); + nextVisited.add(realPath); + return this.expandAgentImports( + imported, + path.dirname(realPath), + depth + 1, + nextVisited, + ); + } + async selectDirectory(): Promise { const paths = await this.dialog.pickFile({ title: "Select a repository folder", From f9386f21ef0c65398ada28280215f01cdc70255c Mon Sep 17 00:00:00 2001 From: Matt Brooker Date: Tue, 14 Jul 2026 14:12:15 -0400 Subject: [PATCH 2/2] fix: track fence length, indented code, and multi-backtick spans in import expansion A closing fence must now match the opening character, be at least as long, and carry no info string, so a three-backtick line inside a four-backtick block no longer closes it. Indented code blocks (4+ spaces or tab after a blank line) stay literal without skipping list continuations. Inline spans match equal-length backtick runs per CommonMark, so double-backtick spans stay literal and an unmatched backtick run no longer swallows the rest of the line. Co-Authored-By: Claude Fable 5 --- .../src/services/os/os.test.ts | 147 ++++++++++++++++++ .../workspace-server/src/services/os/os.ts | 128 ++++++++++++--- 2 files changed, 251 insertions(+), 24 deletions(-) diff --git a/packages/workspace-server/src/services/os/os.test.ts b/packages/workspace-server/src/services/os/os.test.ts index f9b86dc279..8a2243befe 100644 --- a/packages/workspace-server/src/services/os/os.test.ts +++ b/packages/workspace-server/src/services/os/os.test.ts @@ -323,6 +323,153 @@ describe("OsService.getUserAgentInstructions @-import expansion", () => { }, expected: "```\n@./engineering.md\n```", }, + { + label: "a shorter fence line does not close a longer fence", + files: { + [claudePath]: "````\n```\n@./engineering.md\n```\n````", + [engineeringPath]: "engineering rules", + }, + expected: "````\n```\n@./engineering.md\n```\n````", + }, + { + label: "a longer fence line closes a shorter fence", + files: { + [claudePath]: "```\n@./engineering.md\n````\n@./engineering.md", + [engineeringPath]: "engineering rules", + }, + expected: "```\n@./engineering.md\n````\nengineering rules", + }, + { + label: "a backtick fence line does not close a tilde fence", + files: { + [claudePath]: "~~~\n```\n@./engineering.md\n~~~", + [engineeringPath]: "engineering rules", + }, + expected: "~~~\n```\n@./engineering.md\n~~~", + }, + { + label: "a tilde fence line does not close a backtick fence", + files: { + [claudePath]: "```\n~~~\n@./engineering.md\n```", + [engineeringPath]: "engineering rules", + }, + expected: "```\n~~~\n@./engineering.md\n```", + }, + { + label: "expands after a normally closed fence", + files: { + [claudePath]: "```\n@./engineering.md\n```\n@./engineering.md", + [engineeringPath]: "engineering rules", + }, + expected: "```\n@./engineering.md\n```\nengineering rules", + }, + { + label: "a fence line with an info string is not a closer", + files: { + [claudePath]: "```\n@./engineering.md\n``` js\n@./engineering.md\n```", + [engineeringPath]: "engineering rules", + }, + expected: "```\n@./engineering.md\n``` js\n@./engineering.md\n```", + }, + { + label: + "a backtick run with backticks in its info string is a span, not a fence", + files: { + [claudePath]: "```@x```\n@./engineering.md", + [engineeringPath]: "engineering rules", + }, + expected: "```@x```\nengineering rules", + }, + { + label: "keeps an indented code line after a blank line literal", + files: { + [claudePath]: "intro\n\n @./engineering.md", + [engineeringPath]: "engineering rules", + }, + expected: "intro\n\n @./engineering.md", + }, + { + label: "keeps a tab-indented code line after a blank line literal", + files: { + [claudePath]: "intro\n\n\t@./engineering.md", + [engineeringPath]: "engineering rules", + }, + expected: "intro\n\n\t@./engineering.md", + }, + { + label: "expands an indented list continuation without a preceding blank", + files: { + [claudePath]: "- see:\n @./engineering.md", + [engineeringPath]: "engineering rules", + }, + expected: "- see:\n engineering rules", + }, + { + label: + "an indented code block survives internal blanks and ends on dedent", + files: { + [claudePath]: + "intro\n\n @./engineering.md\n\nafter @./engineering.md", + [engineeringPath]: "engineering rules", + }, + expected: "intro\n\n @./engineering.md\n\nafter engineering rules", + }, + { + label: "a three-space indent is not an indented code block", + files: { + [claudePath]: "intro\n\n @./engineering.md", + [engineeringPath]: "engineering rules", + }, + expected: "intro\n\n engineering rules", + }, + { + label: "a four-space-indented fence line is indented code, not a fence", + files: { + [claudePath]: "intro\n\n ````\n@./engineering.md", + [engineeringPath]: "engineering rules", + }, + expected: "intro\n\n ````\nengineering rules", + }, + { + label: "does not expand imports inside double-backtick code spans", + files: { + [claudePath]: "see `` @./engineering.md `` then @./engineering.md", + [engineeringPath]: "engineering rules", + }, + expected: "see `` @./engineering.md `` then engineering rules", + }, + { + label: "a double-backtick span may contain a single backtick", + files: { + [claudePath]: "`` a`b `` @./engineering.md", + [engineeringPath]: "engineering rules", + }, + expected: "`` a`b `` engineering rules", + }, + { + label: "expands after an unmatched backtick run", + files: { + [claudePath]: "a lone ` backtick then @./engineering.md", + [engineeringPath]: "engineering rules", + }, + expected: "a lone ` backtick then engineering rules", + }, + { + label: "expands outside a span at the start of a line", + files: { + [claudePath]: "`@./engineering.md` and @./engineering.md", + [engineeringPath]: "engineering rules", + }, + expected: "`@./engineering.md` and engineering rules", + }, + { + label: "expands between two code spans", + files: { + [claudePath]: "`a` @./engineering.md `b`", + [engineeringPath]: "engineering rules", + }, + expected: "`a` engineering rules `b`", + }, ])("$label", async ({ files, expected }) => { const { service } = createService(); givenFiles(files); diff --git a/packages/workspace-server/src/services/os/os.ts b/packages/workspace-server/src/services/os/os.ts index 0ff2ff09d2..b7da293344 100644 --- a/packages/workspace-server/src/services/os/os.ts +++ b/packages/workspace-server/src/services/os/os.ts @@ -63,7 +63,15 @@ const USER_AGENT_INSTRUCTIONS_CANDIDATES: ReadonlyArray<[string, string]> = [ // stub CLAUDE.md that only `@`-imports its real rules still syncs those rules. const USER_AGENT_INSTRUCTIONS_MAX_IMPORT_DEPTH = 4; const AGENT_IMPORT_PATTERN_SOURCE = "(^|\\s)@(\\S+)"; -const FENCE_PATTERN = /^\s*(`{3,}|~{3,})/; +// Up to 3 leading spaces per CommonMark; 4+ is an indented code line, not a fence. +const FENCE_LINE_PATTERN = /^ {0,3}(`{3,}|~{3,})(.*)$/; +const INDENTED_CODE_PATTERN = /^( {4}|\t)/; + +function backtickRunEnd(line: string, start: number): number { + let end = start; + while (end < line.length && line[end] === "`") end++; + return end; +} @injectable() export class OsService { @@ -153,24 +161,58 @@ export class OsService { const lines = content.split("\n"); const expandedLines: string[] = []; - let fenceMarker: string | null = null; + let fence: { char: string; length: number } | null = null; + let inIndentedCode = false; + let prevBlank = true; for (const line of lines) { - const fence = line.match(FENCE_PATTERN); - if (fence) { - const marker = fence[1][0]; - if (fenceMarker === null) fenceMarker = marker; - else if (marker === fenceMarker) fenceMarker = null; + const isBlank = line.trim() === ""; + const fenceLine = line.match(FENCE_LINE_PATTERN); + + if (fence !== null) { + // A closing fence must match the opening character, be at least as + // long, and carry no info string; anything else is fence content. + if ( + fenceLine && + fenceLine[1][0] === fence.char && + fenceLine[1].length >= fence.length && + fenceLine[2].trim() === "" + ) { + fence = null; + } expandedLines.push(line); - continue; - } - if (fenceMarker !== null) { + } else if ( + fenceLine && + (fenceLine[1][0] === "~" || !fenceLine[2].includes("`")) + ) { + // A backtick fence's info string may not contain a backtick — that + // guard keeps prose like ```@x``` from opening an unterminated fence. + fence = { char: fenceLine[1][0], length: fenceLine[1].length }; + inIndentedCode = false; expandedLines.push(line); - continue; + } else if ( + inIndentedCode && + (isBlank || INDENTED_CODE_PATTERN.test(line)) + ) { + expandedLines.push(line); + } else if ( + !inIndentedCode && + prevBlank && + !isBlank && + INDENTED_CODE_PATTERN.test(line) + ) { + // Indented code blocks only start after a blank line; a 4-space line + // mid-paragraph or under a list item is continuation text whose + // imports should still expand. + inIndentedCode = true; + expandedLines.push(line); + } else { + inIndentedCode = false; + expandedLines.push( + await this.expandImportsInLine(line, baseDir, depth, visited), + ); } - expandedLines.push( - await this.expandImportsInLine(line, baseDir, depth, visited), - ); + prevBlank = isBlank; } return expandedLines.join("\n"); @@ -182,17 +224,55 @@ export class OsService { depth: number, visited: Set, ): Promise { - // Odd-indexed segments sit inside single-backtick code spans, where - // Claude Code treats `@path` as literal text rather than an import. - const segments = line.split("`"); - const rebuilt = await Promise.all( - segments.map((segment, index) => - index % 2 === 0 - ? this.expandImportsInSegment(segment, baseDir, depth, visited) - : Promise.resolve(segment), - ), + // Imports inside code spans stay literal. Per CommonMark, a span opens + // with a backtick run and closes on the next run of exactly the same + // length; runs of other lengths are span content, and an unmatched run + // is plain text. + let result = ""; + let textStart = 0; + let i = 0; + while (i < line.length) { + if (line[i] !== "`") { + i++; + continue; + } + const openEnd = backtickRunEnd(line, i); + const runLength = openEnd - i; + let j = openEnd; + let closeStart = -1; + while (j < line.length) { + if (line[j] !== "`") { + j++; + continue; + } + const runEnd = backtickRunEnd(line, j); + if (runEnd - j === runLength) { + closeStart = j; + break; + } + j = runEnd; + } + if (closeStart === -1) { + i = openEnd; + continue; + } + result += await this.expandImportsInSegment( + line.slice(textStart, i), + baseDir, + depth, + visited, + ); + result += line.slice(i, closeStart + runLength); + textStart = closeStart + runLength; + i = textStart; + } + result += await this.expandImportsInSegment( + line.slice(textStart), + baseDir, + depth, + visited, ); - return rebuilt.join("`"); + return result; } private async expandImportsInSegment(