Skip to content

Commit 7212e12

Browse files
committed
feat(config): improve config file pathfinding
1 parent 7bf6a4d commit 7212e12

3 files changed

Lines changed: 188 additions & 53 deletions

File tree

src/config.test.ts

Lines changed: 167 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,98 @@ import { assertFalse, assertStrictEquals } from "jsr:@std/assert@^1.0.0";
22
import { describe, it } from "jsr:@std/testing@^1.0.0/bdd";
33
import { join } from "node:path";
44
import { loadConfig } from "./config.ts";
5+
import type { GraphitiConfig } from "./types/index.ts";
6+
7+
async function withTempDir<T>(
8+
fn: (directory: string) => T | Promise<T>,
9+
): Promise<T> {
10+
const directory = await Deno.makeTempDir();
11+
12+
try {
13+
return await fn(directory);
14+
} finally {
15+
await Deno.remove(directory, { recursive: true });
16+
}
17+
}
18+
19+
function withTempDirAsCwd<T>(
20+
fn: (directory: string) => T | Promise<T>,
21+
): Promise<T> {
22+
const previousCwd = Deno.cwd();
23+
24+
return withTempDir(async (directory) => {
25+
try {
26+
Deno.chdir(directory);
27+
return await fn(directory);
28+
} finally {
29+
Deno.chdir(previousCwd);
30+
}
31+
});
32+
}
33+
34+
function assertConfigValues(
35+
config: GraphitiConfig,
36+
expected: Pick<
37+
GraphitiConfig,
38+
"endpoint" | "groupIdPrefix" | "driftThreshold" | "factStaleDays"
39+
>,
40+
) {
41+
assertStrictEquals(config.endpoint, expected.endpoint);
42+
assertStrictEquals(config.groupIdPrefix, expected.groupIdPrefix);
43+
assertStrictEquals(config.driftThreshold, expected.driftThreshold);
44+
assertStrictEquals(config.factStaleDays, expected.factStaleDays);
45+
}
46+
47+
const explicitDirectoryConfigCases = [
48+
{
49+
name:
50+
"should load project-local .graphitirc via explicit directory argument",
51+
fileName: ".graphitirc",
52+
fileContents: {
53+
endpoint: "http://project.local/mcp",
54+
driftThreshold: 0.4,
55+
factStaleDays: 7,
56+
},
57+
expected: {
58+
endpoint: "http://project.local/mcp",
59+
groupIdPrefix: "opencode",
60+
driftThreshold: 0.4,
61+
factStaleDays: 7,
62+
},
63+
},
64+
{
65+
name:
66+
"should load project-local package.json graphiti key via explicit directory argument",
67+
fileName: "package.json",
68+
fileContents: {
69+
name: "my-project",
70+
graphiti: {
71+
endpoint: "http://pkg-project.local/mcp",
72+
driftThreshold: 0.2,
73+
factStaleDays: 5,
74+
},
75+
},
76+
expected: {
77+
endpoint: "http://pkg-project.local/mcp",
78+
groupIdPrefix: "opencode",
79+
driftThreshold: 0.2,
80+
factStaleDays: 5,
81+
},
82+
},
83+
] satisfies Array<{
84+
name: string;
85+
fileName: string;
86+
fileContents: unknown;
87+
expected: Pick<
88+
GraphitiConfig,
89+
"endpoint" | "groupIdPrefix" | "driftThreshold" | "factStaleDays"
90+
>;
91+
}>;
592

693
describe("config", () => {
794
describe("loadConfig", () => {
895
it("should load config from package.json graphiti key", async () => {
9-
const cwd = await Deno.makeTempDir();
10-
const previousCwd = Deno.cwd();
11-
try {
96+
await withTempDirAsCwd(async (cwd) => {
1297
const packageConfig = {
1398
name: "opencode-graphiti",
1499
graphiti: {
@@ -22,21 +107,18 @@ describe("config", () => {
22107
JSON.stringify(packageConfig, null, 2),
23108
);
24109

25-
Deno.chdir(cwd);
26110
const config = loadConfig();
27-
assertStrictEquals(config.endpoint, "http://example.com");
28-
assertStrictEquals(config.driftThreshold, 0.3);
29-
assertStrictEquals(config.factStaleDays, 14);
30-
} finally {
31-
Deno.chdir(previousCwd);
32-
await Deno.remove(cwd, { recursive: true });
33-
}
111+
assertConfigValues(config, {
112+
endpoint: "http://example.com",
113+
groupIdPrefix: "opencode",
114+
driftThreshold: 0.3,
115+
factStaleDays: 14,
116+
});
117+
});
34118
});
35119

36120
it("should load config from .graphitirc when present", async () => {
37-
const cwd = await Deno.makeTempDir();
38-
const previousCwd = Deno.cwd();
39-
try {
121+
await withTempDirAsCwd(async (cwd) => {
40122
const rcConfig = {
41123
endpoint: "http://rc.local",
42124
driftThreshold: 0.7,
@@ -47,55 +129,91 @@ describe("config", () => {
47129
JSON.stringify(rcConfig, null, 2),
48130
);
49131

50-
Deno.chdir(cwd);
51132
const config = loadConfig();
52-
assertStrictEquals(config.endpoint, "http://rc.local");
53-
assertStrictEquals(config.driftThreshold, 0.7);
54-
assertStrictEquals(config.factStaleDays, 21);
55-
} finally {
56-
Deno.chdir(previousCwd);
57-
await Deno.remove(cwd, { recursive: true });
58-
}
133+
assertConfigValues(config, {
134+
endpoint: "http://rc.local",
135+
groupIdPrefix: "opencode",
136+
driftThreshold: 0.7,
137+
factStaleDays: 21,
138+
});
139+
});
59140
});
60141

61142
it("should return default config when file does not exist", async () => {
62-
const cwd = await Deno.makeTempDir();
63-
const previousCwd = Deno.cwd();
64-
try {
65-
Deno.chdir(cwd);
143+
await withTempDirAsCwd(() => {
66144
const config = loadConfig();
67-
// Should have all default fields
68-
assertStrictEquals(typeof config.endpoint, "string");
69-
assertStrictEquals(typeof config.groupIdPrefix, "string");
70-
assertStrictEquals(typeof config.driftThreshold, "number");
71-
assertStrictEquals(typeof config.factStaleDays, "number");
72-
73-
// Verify default values
74-
assertStrictEquals(config.endpoint, "http://localhost:8000/mcp");
75-
assertStrictEquals(config.groupIdPrefix, "opencode");
76-
assertStrictEquals(config.driftThreshold, 0.5);
77-
assertStrictEquals(config.factStaleDays, 30);
78-
} finally {
79-
Deno.chdir(previousCwd);
80-
await Deno.remove(cwd, { recursive: true });
81-
}
145+
assertConfigValues(config, {
146+
endpoint: "http://localhost:8000/mcp",
147+
groupIdPrefix: "opencode",
148+
driftThreshold: 0.5,
149+
factStaleDays: 30,
150+
});
151+
});
82152
});
83153

84154
it("should return a valid GraphitiConfig type", async () => {
85-
const cwd = await Deno.makeTempDir();
86-
const previousCwd = Deno.cwd();
87-
try {
88-
Deno.chdir(cwd);
155+
await withTempDirAsCwd(() => {
89156
const config = loadConfig();
90157
// Type checking via runtime assertions
91158
assertFalse(config.endpoint === undefined);
92159
assertFalse(config.groupIdPrefix === undefined);
93160
assertFalse(config.driftThreshold === undefined);
94161
assertFalse(config.factStaleDays === undefined);
95-
} finally {
96-
Deno.chdir(previousCwd);
97-
await Deno.remove(cwd, { recursive: true });
98-
}
162+
});
163+
});
164+
165+
// --- directory-argument tests ---
166+
167+
for (const testCase of explicitDirectoryConfigCases) {
168+
it(testCase.name, async () => {
169+
await withTempDir(async (projectDir) => {
170+
await Deno.writeTextFile(
171+
join(projectDir, testCase.fileName),
172+
JSON.stringify(testCase.fileContents, null, 2),
173+
);
174+
175+
const config = loadConfig(projectDir);
176+
assertConfigValues(config, testCase.expected);
177+
});
178+
});
179+
}
180+
181+
it("should fall back to the same global/default config when directory argument points to a dir with no config", async () => {
182+
await withTempDirAsCwd(async () => {
183+
const fallbackConfig = loadConfig();
184+
185+
await withTempDir((emptyDir) => {
186+
const config = loadConfig(emptyDir);
187+
assertConfigValues(config, fallbackConfig);
188+
});
189+
});
190+
});
191+
192+
it("project-local config overrides when both project and CWD configs differ", async () => {
193+
await withTempDir(async (projectDir) => {
194+
await withTempDirAsCwd(async (otherDir) => {
195+
// CWD has one endpoint, project dir has another
196+
await Deno.writeTextFile(
197+
join(otherDir, ".graphitirc"),
198+
JSON.stringify({ endpoint: "http://cwd.local/mcp" }, null, 2),
199+
);
200+
await Deno.writeTextFile(
201+
join(projectDir, ".graphitirc"),
202+
JSON.stringify(
203+
{ endpoint: "http://project-override.local/mcp" },
204+
null,
205+
2,
206+
),
207+
);
208+
209+
const config = loadConfig(projectDir);
210+
// Must pick project dir, not CWD
211+
assertStrictEquals(
212+
config.endpoint,
213+
"http://project-override.local/mcp",
214+
);
215+
});
216+
});
99217
});
100218
});
101219
});

src/config.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,29 @@ const GraphitiConfigSchema = z.object({
1616
factStaleDays: z.number(),
1717
});
1818

19+
function searchConfig(searchStrategy: "none" | "global", directory?: string) {
20+
const explorer = cosmiconfigSync("graphiti", {
21+
searchStrategy,
22+
cache: false,
23+
});
24+
25+
return directory ? explorer.search(directory) : explorer.search();
26+
}
27+
1928
/**
2029
* Load Graphiti configuration from JSONC files with defaults applied.
30+
*
31+
* When `directory` is provided, the search starts from that directory (no
32+
* upward traversal past it) so that a project-local `.graphitirc` or
33+
* `package.json#graphiti` key takes precedence over any global/home config.
34+
* If no config is found in the project directory the search falls back to a
35+
* global search (home directory and OS-level config locations).
2136
*/
22-
export function loadConfig(): GraphitiConfig {
23-
const explorer = cosmiconfigSync("graphiti", { searchStrategy: "global" });
24-
const result = explorer.search();
37+
export function loadConfig(directory?: string): GraphitiConfig {
38+
const result = directory
39+
? searchConfig("none", directory) ?? searchConfig("global")
40+
: searchConfig("global");
41+
2542
const candidate = result?.config ?? {};
2643
const merged = {
2744
...DEFAULT_CONFIG,

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { makeGroupId, makeUserGroupId } from "./utils.ts";
1313
* OpenCode plugin entry point for Graphiti memory integration.
1414
*/
1515
export const graphiti: Plugin = async (input: PluginInput) => {
16-
const config = loadConfig();
16+
const config = loadConfig(input.directory);
1717
const client = new GraphitiClient(config.endpoint);
1818
const sdkClient = input.client;
1919

0 commit comments

Comments
 (0)