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..8bedb8e 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -329,6 +329,39 @@ 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` — 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 + 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`. + +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 For documentation stored locally in your project: @@ -730,6 +763,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..352658e 100644 --- a/packages/cli/src/api/create.ts +++ b/packages/cli/src/api/create.ts @@ -29,7 +29,11 @@ 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. + // 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[] }; let configPath: string; let configCreated = false; @@ -40,7 +44,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/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 37d7291..5737b1e 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,125 @@ 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, { includeHome: true }); + expect(result).toBe(configPath); + }); + + test("should find config in home directory (sync)", async () => { + const configPath = await writeConfig(homeDir); + + const result = findConfigPathSync(testDir, { includeHome: true }); + 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, { 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`, () => { + 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..9cc9c79 100644 --- a/packages/core/src/config/discovery.ts +++ b/packages/core/src/config/discovery.ts @@ -4,33 +4,65 @@ 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(), + ); +} + +/** + * 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( + startPath: string, + includeHome: boolean, +): Generator { + const visited = new Set(); + 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 - } + const configPath = configPathFor(currentDir); + visited.add(configPath); + yield configPath; - // Move up one directory const parentDir = dirname(currentDir); if (parentDir === currentDir) { // Reached filesystem root @@ -39,40 +71,89 @@ export async function findConfigPath( currentDir = parentDir; } + if (includeHome) { + const homeConfigPath = configPathFor(homedir()); + if (!visited.has(homeConfigPath)) { + yield homeConfigPath; + } + } +} + +/** + * Find the configuration file, in this order: the KNOWLEDGE_SUBDIR override, + * 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. `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( + 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 ?? false, + )) { + 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); - - 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 - } + const configuredDir = directoryFromEnv(CONFIG_SUBDIR_ENV); + if (configuredDir) { + const configPath = join(configuredDir, CONFIG_FILENAME); + return isFileSync(configPath) ? configPath : null; + } - // Move up one directory - const parentDir = dirname(currentDir); - if (parentDir === currentDir) { - // Reached filesystem root - break; + for (const configPath of candidateConfigPaths( + resolveStartPath(startPath), + options.includeHome ?? false, + )) { + if (isFileSync(configPath)) { + return configPath; } - 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..3e72491 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"; @@ -14,6 +19,7 @@ import { findConfigPath } from "./discovery.js"; */ export class ConfigManager { private configCache: { + key: string; config: KnowledgeConfig; configPath: string; loadTime: number; @@ -23,16 +29,22 @@ 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( startDir?: string, + 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 { @@ -42,11 +54,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${options?.includeHome ? " or home directory" : ""}, or set ${CONFIG_SUBDIR_ENV} to the directory holding it.`, { searchPath: startDir || process.cwd() }, ); } @@ -55,6 +67,7 @@ export class ConfigManager { // Cache the result this.configCache = { + key: cacheKey, config, configPath, loadTime: now, @@ -154,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); @@ -186,7 +205,7 @@ export class ConfigManager { } // Save updated configuration - await this.saveConfig(config); + await this.saveConfig(config, configPath); return config; } @@ -194,10 +213,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 +233,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..eef7c5d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -237,3 +237,31 @@ 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. 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 a9afa0a..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 } @@ -353,8 +357,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 +421,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" + @@ -493,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