From e9be7bb2b1dbe9ed1bd49007ba6b4da4ed4dc305 Mon Sep 17 00:00:00 2001 From: Florian Imdahl Date: Thu, 20 Aug 2026 13:32:37 +0200 Subject: [PATCH 1/2] fix(core): resolve config outside the working directory --- README.md | 4 +- USER_GUIDE.md | 26 +++ .../cli/src/__tests__/cli-integration.test.ts | 10 +- packages/cli/src/api/create.ts | 7 +- packages/core/src/__tests__/discovery.test.ts | 148 +++++++++++++++- packages/core/src/config/discovery.ts | 161 +++++++++++++----- packages/core/src/config/manager.ts | 30 +++- packages/core/src/types.ts | 21 +++ packages/mcp-server/src/server.ts | 10 +- 9 files changed, 359 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 1cd2f14..371cd85 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,9 @@ npx @codemcp/knowledge init react-docs **Option B: Manual Configuration** -Create `.knowledge/config.yaml`: +Create `.knowledge/config.yaml` (in your project, or in your home directory for +docsets that should be available everywhere; `PROJECT_DIR` and +`KNOWLEDGE_SUBDIR` override where the server looks): ```yaml version: "1.0" diff --git a/USER_GUIDE.md b/USER_GUIDE.md index e2eb8c5..6c5b5db 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -329,6 +329,26 @@ npx @codemcp/knowledge refresh --config /path/to/config.yaml Place your configuration file at `.knowledge/config.yaml` in your project root. +The configuration is resolved in this order: + +1. `KNOWLEDGE_SUBDIR`, if set: the directory holding `config.yaml`. When that + directory has no `config.yaml`, no configuration is loaded — the override is + never silently ignored. +2. `config.yaml` in a `.knowledge` directory, searched upwards from + `PROJECT_DIR` if set, otherwise from the working directory. +3. `~/.knowledge/config.yaml` in your home directory, as a shared fallback for + docsets you want available everywhere. + +Steps 1 and 3 exist because GUI launchers give the server a working directory +that has nothing to do with your project: Claude Desktop reports +`/Applications`, VS Code its own app bundle. Walking up from there reaches +neither your project nor your home directory, so set `PROJECT_DIR` (or +`KNOWLEDGE_SUBDIR`) in the server environment, or keep the docsets in +`~/.knowledge/config.yaml`. + +Note that `create` never writes to the home configuration: without a project +configuration it creates `.knowledge/config.yaml` in the current directory. + ### Local Folder Sources For documentation stored locally in your project: @@ -730,6 +750,12 @@ The AI assistant will: **Solution**: Create `.knowledge/config.yaml` or the server will start with no docsets (shows setup instructions in tool descriptions). +If the configuration exists but the server does not see it, the client most +likely starts the server outside your project tree (a GUI launcher reports its +own working directory). Set `PROJECT_DIR` to your project or `KNOWLEDGE_SUBDIR` +to the `.knowledge` directory in the server's environment, or put the docsets in +`~/.knowledge/config.yaml`. + ### Docset Not Initialized **Error**: "Docset 'X' is not initialized" diff --git a/packages/cli/src/__tests__/cli-integration.test.ts b/packages/cli/src/__tests__/cli-integration.test.ts index 0da9032..c21b309 100644 --- a/packages/cli/src/__tests__/cli-integration.test.ts +++ b/packages/cli/src/__tests__/cli-integration.test.ts @@ -42,7 +42,7 @@ docsets: url: "https://github.com/microsoft/TypeScript.git" branch: "main" paths: ["README.md"] - + - id: "unsupported-source-docset" name: "Unsupported Source Documentation" description: "Test documentation with git repo source" @@ -90,6 +90,14 @@ docsets: execSync(`node ${cliPath} status`, { encoding: "utf8", timeout: 5000, + // Keep the home directory fallback and env overrides out of this test + env: { + ...process.env, + HOME: emptyDir, + USERPROFILE: emptyDir, + KNOWLEDGE_SUBDIR: "", + PROJECT_DIR: "", + }, }); }).toThrow(); } catch (error: any) { diff --git a/packages/cli/src/api/create.ts b/packages/cli/src/api/create.ts index 87912b4..a2fb67c 100644 --- a/packages/cli/src/api/create.ts +++ b/packages/cli/src/api/create.ts @@ -29,7 +29,10 @@ export async function createDocset( const cwd = options?.cwd ?? process.cwd(); const configManager = new ConfigManager(); - const configExists = await configManager.configExists(cwd); + // The home config is never a write target for a project that has none: + // creating a docset here would silently edit the user's global config + const discovery = { includeHome: false }; + const configExists = await configManager.configExists(cwd, discovery); let config: { version: string; docsets: DocsetConfig[] }; let configPath: string; let configCreated = false; @@ -40,7 +43,7 @@ export async function createDocset( await fs.mkdir(path.dirname(configPath), { recursive: true }); configCreated = true; } else { - const loaded = await configManager.loadConfig(cwd); + const loaded = await configManager.loadConfig(cwd, discovery); config = loaded.config; configPath = loaded.configPath; } diff --git a/packages/core/src/__tests__/discovery.test.ts b/packages/core/src/__tests__/discovery.test.ts index 37d7291..93f1fb9 100644 --- a/packages/core/src/__tests__/discovery.test.ts +++ b/packages/core/src/__tests__/discovery.test.ts @@ -7,20 +7,63 @@ import { promises as fs } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { findConfigPath, findConfigPathSync } from "../config/discovery.js"; -import { CONFIG_DIR, CONFIG_FILENAME } from "../types.js"; +import { + CONFIG_DIR, + CONFIG_FILENAME, + CONFIG_SUBDIR_ENV, + PROJECT_DIR_ENV, +} from "../types.js"; + +const CONFIG_CONTENT = 'version: "1.0"\ndocsets: []'; describe("Configuration Discovery", () => { let tempDir: string; let testDir: string; + let homeDir: string; + let originalEnv: Record; + + /** + * Write a config file for a directory and return its path + */ + async function writeConfig(directory: string): Promise { + const knowledgeDir = join(directory, CONFIG_DIR); + await fs.mkdir(knowledgeDir, { recursive: true }); + const configPath = join(knowledgeDir, CONFIG_FILENAME); + await fs.writeFile(configPath, CONFIG_CONTENT); + return configPath; + } beforeEach(async () => { // Create a temporary directory for testing tempDir = await fs.mkdtemp(join(tmpdir(), "agentic-knowledge-test-")); testDir = join(tempDir, "project"); await fs.mkdir(testDir, { recursive: true }); + + // Point the home fallback at an empty directory so a real ~/.knowledge + // config on the developer machine cannot influence the results + homeDir = join(tempDir, "home"); + await fs.mkdir(homeDir, { recursive: true }); + originalEnv = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + [CONFIG_SUBDIR_ENV]: process.env[CONFIG_SUBDIR_ENV], + [PROJECT_DIR_ENV]: process.env[PROJECT_DIR_ENV], + }; + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + delete process.env[CONFIG_SUBDIR_ENV]; + delete process.env[PROJECT_DIR_ENV]; }); afterEach(async () => { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + // Clean up temporary directory await fs.rm(tempDir, { recursive: true, force: true }); }); @@ -104,6 +147,109 @@ describe("Configuration Discovery", () => { }); }); + describe("home directory fallback", () => { + test("should find config in home directory when the tree has none", async () => { + const configPath = await writeConfig(homeDir); + + const result = await findConfigPath(testDir); + expect(result).toBe(configPath); + }); + + test("should find config in home directory (sync)", async () => { + const configPath = await writeConfig(homeDir); + + const result = findConfigPathSync(testDir); + expect(result).toBe(configPath); + }); + + test("should prefer a config in the tree over the home directory", async () => { + const projectConfigPath = await writeConfig(testDir); + await writeConfig(homeDir); + + const result = await findConfigPath(testDir); + expect(result).toBe(projectConfigPath); + }); + }); + + describe(`${CONFIG_SUBDIR_ENV} override`, () => { + test("should use the configured directory", async () => { + const customDir = join(tempDir, "custom"); + await fs.mkdir(customDir, { recursive: true }); + const configPath = join(customDir, CONFIG_FILENAME); + await fs.writeFile(configPath, CONFIG_CONTENT); + await writeConfig(testDir); + process.env[CONFIG_SUBDIR_ENV] = customDir; + + const result = await findConfigPath(testDir); + expect(result).toBe(configPath); + }); + + test("should use the configured directory (sync)", async () => { + const customDir = join(tempDir, "custom"); + await fs.mkdir(customDir, { recursive: true }); + const configPath = join(customDir, CONFIG_FILENAME); + await fs.writeFile(configPath, CONFIG_CONTENT); + process.env[CONFIG_SUBDIR_ENV] = customDir; + + const result = findConfigPathSync(testDir); + expect(result).toBe(configPath); + }); + + test("should return null when the configured directory holds no config", async () => { + await writeConfig(testDir); + process.env[CONFIG_SUBDIR_ENV] = join(tempDir, "missing"); + + const result = await findConfigPath(testDir); + expect(result).toBeNull(); + }); + + test("should ignore an empty value", async () => { + const configPath = await writeConfig(testDir); + process.env[CONFIG_SUBDIR_ENV] = " "; + + const result = await findConfigPath(testDir); + expect(result).toBe(configPath); + }); + }); + + describe(`${PROJECT_DIR_ENV} override`, () => { + test("should search upward from the configured project directory", async () => { + const configPath = await writeConfig(testDir); + const nestedDir = join(testDir, "nested", "deep"); + await fs.mkdir(nestedDir, { recursive: true }); + process.env[PROJECT_DIR_ENV] = nestedDir; + + const result = await findConfigPath(); + expect(result).toBe(configPath); + }); + + test("should search upward from the configured project directory (sync)", async () => { + const configPath = await writeConfig(testDir); + process.env[PROJECT_DIR_ENV] = testDir; + + const result = findConfigPathSync(); + expect(result).toBe(configPath); + }); + + test("should let an explicit start path win", async () => { + const otherDir = join(tempDir, "other"); + await fs.mkdir(otherDir, { recursive: true }); + const explicitConfigPath = await writeConfig(otherDir); + await writeConfig(testDir); + process.env[PROJECT_DIR_ENV] = testDir; + + const result = await findConfigPath(otherDir); + expect(result).toBe(explicitConfigPath); + }); + + test("should ignore an empty value", async () => { + process.env[PROJECT_DIR_ENV] = " "; + + const result = await findConfigPath(testDir); + expect(result).toBeNull(); + }); + }); + describe("edge cases", () => { test("should handle directory with .knowledge but no config.yaml", async () => { // Create .knowledge directory but no config file diff --git a/packages/core/src/config/discovery.ts b/packages/core/src/config/discovery.ts index 889d9af..d0581c7 100644 --- a/packages/core/src/config/discovery.ts +++ b/packages/core/src/config/discovery.ts @@ -4,33 +4,60 @@ import { promises as fs } from "node:fs"; import * as fsSync from "node:fs"; +import { homedir } from "node:os"; import { resolve, dirname, join } from "node:path"; -import { CONFIG_DIR, CONFIG_FILENAME } from "../types.js"; +import type { ConfigDiscoveryOptions } from "../types.js"; +import { + CONFIG_DIR, + CONFIG_FILENAME, + CONFIG_SUBDIR_ENV, + PROJECT_DIR_ENV, +} from "../types.js"; /** - * Find the configuration path by walking up the directory tree - * @param startPath - Starting directory path (defaults to current working directory) - * @returns Path to config file or null if not found + * Build the conventional config path for a directory */ -export async function findConfigPath( - startPath: string = process.cwd(), -): Promise { - let currentDir = resolve(startPath); +function configPathFor(directory: string): string { + return join(directory, CONFIG_DIR, CONFIG_FILENAME); +} + +/** + * Read a directory override from the environment + * @returns Absolute path, or null when the variable is unset or blank + */ +function directoryFromEnv(variable: string): string | null { + const configured = process.env[variable]?.trim(); + return configured ? resolve(configured) : null; +} + +/** + * Resolve where the upward search starts: an explicit argument wins over the + * ambient PROJECT_DIR, which in turn wins over the working directory. GUI + * launchers give MCP servers an unrelated working directory (Claude Desktop + * reports /Applications, VS Code its own app bundle), so PROJECT_DIR is how + * those clients point the server at a project. + */ +function resolveStartPath(startPath?: string): string { + return resolve( + startPath ?? directoryFromEnv(PROJECT_DIR_ENV) ?? process.cwd(), + ); +} + +/** + * Collect the config paths to probe, from startPath up to the filesystem root, + * optionally followed by the user's home directory as a last resort. The home + * fallback covers the same GUI-launch case when no project directory is known. + */ +function candidateConfigPaths( + startPath: string, + includeHome: boolean, +): string[] { + const paths: string[] = []; + let currentDir = startPath; while (true) { - const configDir = join(currentDir, CONFIG_DIR); - const configPath = join(configDir, CONFIG_FILENAME); - - try { - const stats = await fs.stat(configPath); - if (stats.isFile()) { - return configPath; - } - } catch { - // File doesn't exist, continue searching - } + paths.push(configPathFor(currentDir)); - // Move up one directory const parentDir = dirname(currentDir); if (parentDir === currentDir) { // Reached filesystem root @@ -39,40 +66,90 @@ export async function findConfigPath( currentDir = parentDir; } + if (includeHome) { + const homeConfigPath = configPathFor(homedir()); + if (!paths.includes(homeConfigPath)) { + paths.push(homeConfigPath); + } + } + + return paths; +} + +/** + * Find the configuration file, in this order: the KNOWLEDGE_SUBDIR override, + * an upward walk from the start path, then the user's home directory + * @param startPath - Directory to start searching from. Defaults to PROJECT_DIR + * when set, otherwise the current working directory. + * @param options - Discovery options. Pass `includeHome: false` when the caller + * is about to write to the config, so a home config cannot become the target + * for a project that has none. + * @returns Path to config file or null if not found + */ +export async function findConfigPath( + startPath?: string, + options: ConfigDiscoveryOptions = {}, +): Promise { + const configuredDir = directoryFromEnv(CONFIG_SUBDIR_ENV); + if (configuredDir) { + // An explicit override is never silently ignored + const configPath = join(configuredDir, CONFIG_FILENAME); + return (await isFile(configPath)) ? configPath : null; + } + + for (const configPath of candidateConfigPaths( + resolveStartPath(startPath), + options.includeHome ?? true, + )) { + if (await isFile(configPath)) { + return configPath; + } + } + return null; } /** * Synchronous version of findConfigPath for cases where async is not suitable - * @param startPath - Starting directory path (defaults to current working directory) + * @param startPath - Directory to start searching from, see {@link findConfigPath} + * @param options - Discovery options, see {@link findConfigPath} * @returns Path to config file or null if not found */ export function findConfigPathSync( - startPath: string = process.cwd(), + startPath?: string, + options: ConfigDiscoveryOptions = {}, ): string | null { - let currentDir = resolve(startPath); + const configuredDir = directoryFromEnv(CONFIG_SUBDIR_ENV); + if (configuredDir) { + const configPath = join(configuredDir, CONFIG_FILENAME); + return isFileSync(configPath) ? configPath : null; + } - while (true) { - const configDir = join(currentDir, CONFIG_DIR); - const configPath = join(configDir, CONFIG_FILENAME); - - try { - const stats = fsSync.statSync(configPath); - if (stats.isFile()) { - return configPath; - } - } catch { - // File doesn't exist, continue searching + for (const configPath of candidateConfigPaths( + resolveStartPath(startPath), + options.includeHome ?? true, + )) { + if (isFileSync(configPath)) { + return configPath; } - - // Move up one directory - const parentDir = dirname(currentDir); - if (parentDir === currentDir) { - // Reached filesystem root - break; - } - currentDir = parentDir; } return null; } + +async function isFile(path: string): Promise { + try { + const stats = await fs.stat(path); + return stats.isFile(); + } catch { + return false; + } +} + +function isFileSync(path: string): boolean { + try { + return fsSync.statSync(path).isFile(); + } catch { + return false; + } +} diff --git a/packages/core/src/config/manager.ts b/packages/core/src/config/manager.ts index 66bafcf..1ccb97a 100644 --- a/packages/core/src/config/manager.ts +++ b/packages/core/src/config/manager.ts @@ -4,8 +4,13 @@ import { promises as fs } from "node:fs"; import { load, dump } from "js-yaml"; -import type { KnowledgeConfig } from "../types.js"; -import { KnowledgeError, ErrorType } from "../types.js"; +import type { ConfigDiscoveryOptions, KnowledgeConfig } from "../types.js"; +import { + KnowledgeError, + ErrorType, + CONFIG_FILENAME, + CONFIG_SUBDIR_ENV, +} from "../types.js"; import { validateConfig } from "./loader.js"; import { findConfigPath } from "./discovery.js"; @@ -27,6 +32,7 @@ export class ConfigManager { */ async loadConfig( startDir?: string, + options?: ConfigDiscoveryOptions, ): Promise<{ config: KnowledgeConfig; configPath: string }> { const now = Date.now(); @@ -42,11 +48,11 @@ export class ConfigManager { } // Find and load fresh config - const configPath = await findConfigPath(startDir); + const configPath = await findConfigPath(startDir, options); if (!configPath) { throw new KnowledgeError( ErrorType.CONFIG_NOT_FOUND, - "No configuration file found. Please ensure .knowledge/config.yaml exists in your project.", + `No configuration file found. Please ensure .knowledge/${CONFIG_FILENAME} exists in your project or home directory, or set ${CONFIG_SUBDIR_ENV} to the directory holding it.`, { searchPath: startDir || process.cwd() }, ); } @@ -194,10 +200,14 @@ export class ConfigManager { /** * Find configuration file path * @param startDir - Directory to start searching from + * @param options - Discovery options, see {@link findConfigPath} * @returns Path to config file or null if not found */ - async findConfigPath(startDir?: string): Promise { - return await findConfigPath(startDir); + async findConfigPath( + startDir?: string, + options?: ConfigDiscoveryOptions, + ): Promise { + return await findConfigPath(startDir, options); } /** @@ -210,10 +220,14 @@ export class ConfigManager { /** * Check if configuration exists * @param startDir - Directory to start searching from + * @param options - Discovery options, see {@link findConfigPath} * @returns True if config file exists */ - async configExists(startDir?: string): Promise { - const path = await this.findConfigPath(startDir); + async configExists( + startDir?: string, + options?: ConfigDiscoveryOptions, + ): Promise { + const path = await this.findConfigPath(startDir, options); return path !== null; } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 30cd113..f18db5c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -237,3 +237,24 @@ export const CONFIG_FILENAME = "config.yaml"; * Configuration directory name */ export const CONFIG_DIR = ".knowledge"; + +/** + * Environment variable holding an explicit path to the configuration directory + */ +export const CONFIG_SUBDIR_ENV = "KNOWLEDGE_SUBDIR"; + +/** + * Environment variable holding the project directory to search upwards from + */ +export const PROJECT_DIR_ENV = "PROJECT_DIR"; + +/** + * Options for configuration discovery + */ +export interface ConfigDiscoveryOptions { + /** + * Fall back to the config in the user's home directory when the directory + * tree holds none. Defaults to true. + */ + includeHome?: boolean; +} diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index a9afa0a..534fe43 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -353,8 +353,11 @@ ${config.docsets.map((d) => `• **${d.id}** (${d.name})`).join("\n")}`, 'npx @codemcp/knowledge create --preset git-repo --id my-docs --name "My Docs" --url \n' + "npx @codemcp/knowledge init my-docs\n\n" + "**Option 2: Manual configuration**\n" + - "Create .knowledge/config.yaml in your project root.\n" + - "See the search_docs tool description for example configuration.", + "Create .knowledge/config.yaml in your project root or home directory.\n" + + "See the search_docs tool description for example configuration.\n\n" + + "**Option 3: Point at an existing configuration**\n" + + "Set PROJECT_DIR to the project, or KNOWLEDGE_SUBDIR to the\n" + + ".knowledge directory holding config.yaml.", ); } @@ -414,7 +417,8 @@ ${config.docsets.map((d) => `• **${d.id}** (${d.name})`).join("\n")}`, "npx @codemcp/knowledge init my-docs\n" + "```\n\n" + "**Option 2: Manual configuration**\n" + - "Create `.knowledge/config.yaml`:\n" + + "Create `.knowledge/config.yaml` in the project or home directory\n" + + "(`PROJECT_DIR` or `KNOWLEDGE_SUBDIR` override where the server looks):\n" + "```yaml\n" + 'version: "1.0"\n' + "docsets:\n" + From 40f31be84a86298a9e794df461dab70c25921d3f Mon Sep 17 00:00:00 2001 From: Florian Imdahl Date: Fri, 21 Aug 2026 10:06:47 +0200 Subject: [PATCH 2/2] refactor(core): make the home config fallback opt-in Review follow-up on #59. - includeHome now defaults to false. Discovery falls back to ~/.knowledge/config.yaml only where a machine-wide config is a legitimate answer: the MCP server (its working directory is dictated by the GUI client that launched it) and the CLI commands that operate on an already declared docset (status, init, refresh). Anything that may create a config, i.e. create, keeps the project-only default. - init/refresh keep the fallback on purpose: docsets declared in ~/.knowledge/config.yaml must be manageable from any directory. Their writes target the config that declared the docset, which is now documented. - The init_docset MCP tool no longer passes process.cwd() explicitly, which defeated PROJECT_DIR; it resolves the config like the read path does. - updateDocsetPaths takes the config path the caller resolved instead of re-running discovery, and the ConfigManager cache is keyed by start directory and options. A clone longer than the 60s cache TTL could otherwise make init write discovered paths to a different config. - candidateConfigPaths is a generator, so probing stops at the first hit without materialising the ancestor list. --- USER_GUIDE.md | 23 +++- packages/cli/src/api/create.ts | 3 +- packages/cli/src/api/init.ts | 11 +- packages/cli/src/api/refresh.ts | 6 +- packages/cli/src/api/status.ts | 6 +- .../core/src/__tests__/config-manager.test.ts | 119 ++++++++++++++++++ packages/core/src/__tests__/discovery.test.ts | 22 +++- packages/core/src/config/discovery.ts | 34 ++--- packages/core/src/config/manager.ts | 19 ++- packages/core/src/types.ts | 9 +- packages/mcp-server/src/server.ts | 14 ++- 11 files changed, 229 insertions(+), 37 deletions(-) create mode 100644 packages/core/src/__tests__/config-manager.test.ts diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 6c5b5db..8bedb8e 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -331,9 +331,11 @@ Place your configuration file at `.knowledge/config.yaml` in your project root. The configuration is resolved in this order: -1. `KNOWLEDGE_SUBDIR`, if set: the directory holding `config.yaml`. When that - directory has no `config.yaml`, no configuration is loaded — the override is - never silently ignored. +1. `KNOWLEDGE_SUBDIR`, if set: the directory holding `config.yaml` — usually the + `.knowledge` directory itself (`/path/to/project/.knowledge`), not a + subdirectory inside it and not the project root. When that directory has no + `config.yaml`, no configuration is loaded — the override is never silently + ignored. 2. `config.yaml` in a `.knowledge` directory, searched upwards from `PROJECT_DIR` if set, otherwise from the working directory. 3. `~/.knowledge/config.yaml` in your home directory, as a shared fallback for @@ -346,8 +348,19 @@ neither your project nor your home directory, so set `PROJECT_DIR` (or `KNOWLEDGE_SUBDIR`) in the server environment, or keep the docsets in `~/.knowledge/config.yaml`. -Note that `create` never writes to the home configuration: without a project -configuration it creates `.knowledge/config.yaml` in the current directory. +The home fallback in step 3 is deliberate per command, never a blanket default: + +- The MCP server, `status`, `init` and `refresh` use it. They read or update a + docset that is already declared, so a machine-wide config is a valid answer — + otherwise docsets in `~/.knowledge/config.yaml` could only be managed from + your home directory. +- `create` does not. Without a project configuration it creates + `.knowledge/config.yaml` in the current directory instead of appending the new + docset to your home configuration. + +`init` and `refresh` write to the configuration that declared the docset (and to +the `.knowledge` directory beside it), so initialising a docset that only exists +in `~/.knowledge/config.yaml` updates that file — the config you declared it in. ### Local Folder Sources diff --git a/packages/cli/src/api/create.ts b/packages/cli/src/api/create.ts index a2fb67c..352658e 100644 --- a/packages/cli/src/api/create.ts +++ b/packages/cli/src/api/create.ts @@ -30,7 +30,8 @@ export async function createDocset( const configManager = new ConfigManager(); // The home config is never a write target for a project that has none: - // creating a docset here would silently edit the user's global config + // creating a docset here would silently edit the user's global config. + // This is the default, spelled out because it is load-bearing here. const discovery = { includeHome: false }; const configExists = await configManager.configExists(cwd, discovery); let config: { version: string; docsets: DocsetConfig[] }; diff --git a/packages/cli/src/api/init.ts b/packages/cli/src/api/init.ts index 9b8730e..1a53e8a 100644 --- a/packages/cli/src/api/init.ts +++ b/packages/cli/src/api/init.ts @@ -33,7 +33,14 @@ export async function initDocset( } = params; const configManager = new ConfigManager(); - const { config, configPath } = await configManager.loadConfig(cwd); + // init operates on a docset that is already declared somewhere, so the home + // config is a legitimate source: docsets meant to be available everywhere + // live in ~/.knowledge/config.yaml and must be initialisable from any + // directory. Writes (metadata, .gitignore, discovered paths) go to the + // config that declared the docset — never to a config the user did not mean. + const { config, configPath } = await configManager.loadConfig(cwd, { + includeHome: true, + }); ensureKnowledgeGitignoreSync(configPath); @@ -61,7 +68,7 @@ export async function initDocset( if (allFiles.length > 0) { const discovered = discoverDirectoryPatterns(allFiles); try { - await configManager.updateDocsetPaths(docsetId, discovered); + await configManager.updateDocsetPaths(docsetId, discovered, configPath); } catch { // Non-fatal: surface the discovered paths even if config update failed. } diff --git a/packages/cli/src/api/refresh.ts b/packages/cli/src/api/refresh.ts index 5b7ec0a..0c18c7b 100644 --- a/packages/cli/src/api/refresh.ts +++ b/packages/cli/src/api/refresh.ts @@ -56,10 +56,12 @@ export async function refreshDocsets( ): Promise { const { docsetId, force = false, cwd = process.cwd() } = params ?? {}; - const configPath = findConfigPathSync(cwd); + // Like init, refresh works on already declared docsets, so a machine-wide + // ~/.knowledge/config.yaml is a legitimate source to refresh from + const configPath = findConfigPathSync(cwd, { includeHome: true }); if (!configPath) { throw new Error( - "No configuration file found. Ensure .knowledge/config.yaml exists in the project.", + "No configuration file found. Ensure .knowledge/config.yaml exists in the project or your home directory.", ); } diff --git a/packages/cli/src/api/status.ts b/packages/cli/src/api/status.ts index f33cd3a..f84f320 100644 --- a/packages/cli/src/api/status.ts +++ b/packages/cli/src/api/status.ts @@ -43,10 +43,12 @@ interface RawSourceMetadata { */ export async function getStatus(params?: StatusParams): Promise { const cwd = params?.cwd ?? process.cwd(); - const configPath = findConfigPathSync(cwd); + // Read-only: report on whichever config the other commands would use, + // including a machine-wide ~/.knowledge/config.yaml + const configPath = findConfigPathSync(cwd, { includeHome: true }); if (!configPath) { throw new Error( - "No configuration file found. Ensure .knowledge/config.yaml exists in the project.", + "No configuration file found. Ensure .knowledge/config.yaml exists in the project or your home directory.", ); } diff --git a/packages/core/src/__tests__/config-manager.test.ts b/packages/core/src/__tests__/config-manager.test.ts new file mode 100644 index 0000000..7f1adcd --- /dev/null +++ b/packages/core/src/__tests__/config-manager.test.ts @@ -0,0 +1,119 @@ +/** + * Tests for ConfigManager discovery and write-target behaviour + */ + +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { promises as fs } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { ConfigManager } from "../config/manager.js"; +import { + CONFIG_DIR, + CONFIG_FILENAME, + CONFIG_SUBDIR_ENV, + PROJECT_DIR_ENV, +} from "../types.js"; + +describe("ConfigManager", () => { + let tempDir: string; + let homeDir: string; + let originalEnv: Record; + + async function writeConfig( + directory: string, + docsetId: string, + ): Promise { + const knowledgeDir = join(directory, CONFIG_DIR); + await fs.mkdir(knowledgeDir, { recursive: true }); + const configPath = join(knowledgeDir, CONFIG_FILENAME); + await fs.writeFile( + configPath, + [ + 'version: "1.0"', + "docsets:", + ` - id: ${docsetId}`, + ` name: ${docsetId}`, + " sources:", + " - type: git_repo", + " url: https://example.com/repo.git", + " branch: main", + ].join("\n"), + ); + return configPath; + } + + beforeEach(async () => { + tempDir = await fs.mkdtemp(join(tmpdir(), "agentic-knowledge-manager-")); + homeDir = join(tempDir, "home"); + await fs.mkdir(homeDir, { recursive: true }); + originalEnv = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + [CONFIG_SUBDIR_ENV]: process.env[CONFIG_SUBDIR_ENV], + [PROJECT_DIR_ENV]: process.env[PROJECT_DIR_ENV], + }; + process.env.HOME = homeDir; + process.env.USERPROFILE = homeDir; + delete process.env[CONFIG_SUBDIR_ENV]; + delete process.env[PROJECT_DIR_ENV]; + }); + + afterEach(async () => { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + test("caches per lookup, not per instance", async () => { + const projectA = join(tempDir, "a"); + const projectB = join(tempDir, "b"); + await fs.mkdir(projectA, { recursive: true }); + await fs.mkdir(projectB, { recursive: true }); + const configA = await writeConfig(projectA, "a"); + const configB = await writeConfig(projectB, "b"); + + const manager = new ConfigManager(); + expect((await manager.loadConfig(projectA)).configPath).toBe(configA); + expect((await manager.loadConfig(projectB)).configPath).toBe(configB); + }); + + test("does not share a result between includeHome variants", async () => { + const project = join(tempDir, "project"); + await fs.mkdir(project, { recursive: true }); + const homeConfig = await writeConfig(homeDir, "global"); + + expect( + (await new ConfigManager().loadConfig(project, { includeHome: true })) + .configPath, + ).toBe(homeConfig); + + const manager = new ConfigManager(); + await manager.loadConfig(project, { includeHome: true }); + await expect(manager.loadConfig(project)).rejects.toThrow( + /No configuration file found/, + ); + }); + + test("updateDocsetPaths writes to the config it was given", async () => { + const project = join(tempDir, "project"); + await fs.mkdir(project, { recursive: true }); + const projectConfig = await writeConfig(project, "docs"); + await writeConfig(homeDir, "docs"); + + const manager = new ConfigManager(); + await manager.updateDocsetPaths("docs", ["docs/"], projectConfig); + + const written = await fs.readFile(projectConfig, "utf-8"); + expect(written).toContain("docs/"); + const homeContent = await fs.readFile( + join(homeDir, CONFIG_DIR, CONFIG_FILENAME), + "utf-8", + ); + expect(homeContent).not.toContain("docs/"); + }); +}); diff --git a/packages/core/src/__tests__/discovery.test.ts b/packages/core/src/__tests__/discovery.test.ts index 93f1fb9..5737b1e 100644 --- a/packages/core/src/__tests__/discovery.test.ts +++ b/packages/core/src/__tests__/discovery.test.ts @@ -151,14 +151,14 @@ describe("Configuration Discovery", () => { test("should find config in home directory when the tree has none", async () => { const configPath = await writeConfig(homeDir); - const result = await findConfigPath(testDir); + const result = await findConfigPath(testDir, { includeHome: true }); expect(result).toBe(configPath); }); test("should find config in home directory (sync)", async () => { const configPath = await writeConfig(homeDir); - const result = findConfigPathSync(testDir); + const result = findConfigPathSync(testDir, { includeHome: true }); expect(result).toBe(configPath); }); @@ -166,9 +166,25 @@ describe("Configuration Discovery", () => { const projectConfigPath = await writeConfig(testDir); await writeConfig(homeDir); - const result = await findConfigPath(testDir); + const result = await findConfigPath(testDir, { includeHome: true }); expect(result).toBe(projectConfigPath); }); + + test("should not use the home config unless asked to", async () => { + await writeConfig(homeDir); + + expect(await findConfigPath(testDir)).toBeNull(); + expect(findConfigPathSync(testDir)).toBeNull(); + expect(await findConfigPath(testDir, { includeHome: false })).toBeNull(); + }); + + test("should not consider the home config when the override points elsewhere", async () => { + await writeConfig(homeDir); + process.env[CONFIG_SUBDIR_ENV] = join(tempDir, "missing"); + + const result = await findConfigPath(testDir, { includeHome: true }); + expect(result).toBeNull(); + }); }); describe(`${CONFIG_SUBDIR_ENV} override`, () => { diff --git a/packages/core/src/config/discovery.ts b/packages/core/src/config/discovery.ts index d0581c7..9cc9c79 100644 --- a/packages/core/src/config/discovery.ts +++ b/packages/core/src/config/discovery.ts @@ -44,19 +44,24 @@ function resolveStartPath(startPath?: string): string { } /** - * Collect the config paths to probe, from startPath up to the filesystem root, + * Yield the config paths to probe, from startPath up to the filesystem root, * optionally followed by the user's home directory as a last resort. The home * fallback covers the same GUI-launch case when no project directory is known. + * + * Lazy on purpose: the caller stats each candidate and stops at the first hit, + * so the ancestor list is never materialised. */ -function candidateConfigPaths( +function* candidateConfigPaths( startPath: string, includeHome: boolean, -): string[] { - const paths: string[] = []; +): Generator { + const visited = new Set(); let currentDir = startPath; while (true) { - paths.push(configPathFor(currentDir)); + const configPath = configPathFor(currentDir); + visited.add(configPath); + yield configPath; const parentDir = dirname(currentDir); if (parentDir === currentDir) { @@ -68,22 +73,21 @@ function candidateConfigPaths( if (includeHome) { const homeConfigPath = configPathFor(homedir()); - if (!paths.includes(homeConfigPath)) { - paths.push(homeConfigPath); + if (!visited.has(homeConfigPath)) { + yield homeConfigPath; } } - - return paths; } /** * Find the configuration file, in this order: the KNOWLEDGE_SUBDIR override, - * an upward walk from the start path, then the user's home directory + * an upward walk from the start path, then — only with `includeHome: true` — + * the user's home directory * @param startPath - Directory to start searching from. Defaults to PROJECT_DIR * when set, otherwise the current working directory. - * @param options - Discovery options. Pass `includeHome: false` when the caller - * is about to write to the config, so a home config cannot become the target - * for a project that has none. + * @param options - Discovery options. `includeHome` is opt-in (default `false`) + * so that a home config never silently answers for a project that has none; + * pass `true` where a shared, machine-wide config is a legitimate result. * @returns Path to config file or null if not found */ export async function findConfigPath( @@ -99,7 +103,7 @@ export async function findConfigPath( for (const configPath of candidateConfigPaths( resolveStartPath(startPath), - options.includeHome ?? true, + options.includeHome ?? false, )) { if (await isFile(configPath)) { return configPath; @@ -127,7 +131,7 @@ export function findConfigPathSync( for (const configPath of candidateConfigPaths( resolveStartPath(startPath), - options.includeHome ?? true, + options.includeHome ?? false, )) { if (isFileSync(configPath)) { return configPath; diff --git a/packages/core/src/config/manager.ts b/packages/core/src/config/manager.ts index 1ccb97a..3e72491 100644 --- a/packages/core/src/config/manager.ts +++ b/packages/core/src/config/manager.ts @@ -19,6 +19,7 @@ import { findConfigPath } from "./discovery.js"; */ export class ConfigManager { private configCache: { + key: string; config: KnowledgeConfig; configPath: string; loadTime: number; @@ -28,6 +29,7 @@ export class ConfigManager { /** * Load configuration with caching * @param startDir - Directory to start searching from (defaults to cwd) + * @param options - Discovery options, see {@link findConfigPath} * @returns Configuration and path */ async loadConfig( @@ -35,10 +37,14 @@ export class ConfigManager { options?: ConfigDiscoveryOptions, ): Promise<{ config: KnowledgeConfig; configPath: string }> { const now = Date.now(); + // The cache key covers the lookup, not just the instance: two lookups with + // different start directories or options must not share a resolved config + const cacheKey = `${startDir ?? ""}\u0000${options?.includeHome ?? false}`; // Return cached config if still valid if ( this.configCache && + this.configCache.key === cacheKey && now - this.configCache.loadTime < this.CONFIG_CACHE_TTL ) { return { @@ -52,7 +58,7 @@ export class ConfigManager { if (!configPath) { throw new KnowledgeError( ErrorType.CONFIG_NOT_FOUND, - `No configuration file found. Please ensure .knowledge/${CONFIG_FILENAME} exists in your project or home directory, or set ${CONFIG_SUBDIR_ENV} to the directory holding it.`, + `No configuration file found. Please ensure .knowledge/${CONFIG_FILENAME} exists in your project${options?.includeHome ? " or home directory" : ""}, or set ${CONFIG_SUBDIR_ENV} to the directory holding it.`, { searchPath: startDir || process.cwd() }, ); } @@ -61,6 +67,7 @@ export class ConfigManager { // Cache the result this.configCache = { + key: cacheKey, config, configPath, loadTime: now, @@ -160,13 +167,19 @@ export class ConfigManager { * Update paths for a specific docset with discovered files * @param docsetId - ID of docset to update * @param discoveredPaths - Array of file paths that were discovered + * @param configPath - Config file to update. Pass the path the caller already + * resolved: re-running discovery here could pick a different file than the + * one the docset was read from. * @returns Updated configuration */ async updateDocsetPaths( docsetId: string, discoveredPaths: string[], + configPath?: string, ): Promise { - const { config } = await this.loadConfig(); + const config = configPath + ? await this.loadConfigFromPath(configPath) + : (await this.loadConfig()).config; // Find the docset to update const docset = config.docsets.find((d) => d.id === docsetId); @@ -192,7 +205,7 @@ export class ConfigManager { } // Save updated configuration - await this.saveConfig(config); + await this.saveConfig(config, configPath); return config; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f18db5c..eef7c5d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -254,7 +254,14 @@ export const PROJECT_DIR_ENV = "PROJECT_DIR"; export interface ConfigDiscoveryOptions { /** * Fall back to the config in the user's home directory when the directory - * tree holds none. Defaults to true. + * tree holds none. Opt-in, defaults to false: config normally lives with the + * project, and a silent home fallback would mask a missing project config. + * + * Set it to true where a machine-wide config is a legitimate answer — the MCP + * server (whose working directory is dictated by the GUI client that launched + * it) and the CLI commands that operate on an existing docset. Leave it off + * for anything that may *create* a config, so the home config cannot become + * the write target for a project that has none. */ includeHome?: boolean; } diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 534fe43..07a1e42 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -74,8 +74,12 @@ export function createAgenticKnowledgeServer() { } try { - // Find configuration file path - const configPath = await findConfigPath(); + // Find configuration file path. The server opts into the home fallback: + // its working directory is dictated by the GUI client that launched it + // (Claude Desktop reports /Applications, VS Code its own app bundle), so + // without PROJECT_DIR/KNOWLEDGE_SUBDIR the upward walk reaches nothing + // and ~/.knowledge/config.yaml is the only config it can find. + const configPath = await findConfigPath(undefined, { includeHome: true }); if (!configPath) { return null; // No config file found - server can still start } @@ -497,8 +501,12 @@ ${config.docsets.map((d) => `• **${d.id}** (${d.name})`).join("\n")}`, } const configManager = new ConfigManager(); + // Same resolution as getConfiguration(): passing process.cwd() + // explicitly would defeat PROJECT_DIR, and this tool must initialise + // the docset in the config the other tools read from const { config, configPath } = await configManager.loadConfig( - process.cwd(), + undefined, + { includeHome: true }, ); // Invalidate config cache and search index cache so the next