From b5ca44ef1365b8f73b5ad636b5433db847222cf4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Mar 2026 06:02:38 +0000 Subject: [PATCH 1/3] feat(mcp-server): add init_docset MCP tool Exposes docset initialization directly as an MCP tool so agents can initialize uninitialized docsets without needing to invoke the CLI. The new `init_docset` tool mirrors the CLI `init` command: it downloads and prepares all configured sources (git_repo, local_folder, archive), writes the per-source and overall metadata files, and invalidates the config cache so subsequent `search_docs` calls pick up the new state. - Add `@codemcp/knowledge-content-loader` dependency to mcp-server - Register `init_docset` in both ListTools responses (with/without config) - Implement handler reusing GitRepoLoader / ArchiveLoader / createSymlinks https://claude.ai/code/session_014fPGYkZQTaWzSuLcDejZQD --- packages/cli/src/commands/init.ts | 319 ++------------------- packages/content-loader/package.json | 1 + packages/content-loader/src/docset-init.ts | 286 ++++++++++++++++++ packages/content-loader/src/index.ts | 6 + packages/mcp-server/package.json | 1 + packages/mcp-server/src/server.ts | 106 +++++++ pnpm-lock.yaml | 6 + 7 files changed, 429 insertions(+), 296 deletions(-) create mode 100644 packages/content-loader/src/docset-init.ts diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 72483cb..3970126 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -4,20 +4,14 @@ import { Command } from "commander"; import chalk from "chalk"; -import { promises as fs } from "node:fs"; -import * as path from "node:path"; import { ConfigManager, - calculateLocalPath, ensureKnowledgeGitignoreSync, discoverDirectoryPatterns, - safelyClearDirectory, - getDirectoryInfo, } from "@codemcp/knowledge-core"; import { - GitRepoLoader, - ArchiveLoader, - WebSourceType, + initDocset, + type SourceResult, } from "@codemcp/knowledge-content-loader"; export const initCommand = new Command("init") @@ -38,13 +32,11 @@ export const initCommand = new Command("init") console.log(chalk.blue("šŸš€ Agentic Knowledge Integration Test")); try { - // Use ConfigManager for all config operations const configManager = new ConfigManager(); const { config, configPath } = await configManager.loadConfig( process.cwd(), ); - // Ensure .knowledge/.gitignore exists and contains docsets/ ignore rule ensureKnowledgeGitignoreSync(configPath); const docset = config.docsets.find((d) => d.id === docsetId); @@ -55,313 +47,48 @@ export const initCommand = new Command("init") ); } - if (!docset.sources || docset.sources.length === 0) { - throw new Error(`Docset '${docsetId}' has no sources configured`); - } - console.log(chalk.green(`āœ… Found docset: ${docset.name}`)); console.log(chalk.gray(`šŸ“ Description: ${docset.description}`)); console.log(chalk.gray(`šŸ”— Sources: ${docset.sources.length}`)); - // Calculate the local path for this docset - const localPath = calculateLocalPath(docset, configPath); - - console.log(chalk.yellow(`\nšŸ“ Target directory: ${localPath}`)); - - // Check if already exists - let existsAlready = false; - try { - const stat = await fs.stat(localPath); - if (stat.isDirectory()) { - existsAlready = true; - } - } catch { - // Directory doesn't exist, which is fine - } - - if (existsAlready && !options.force) { - console.log( - chalk.yellow( - "āš ļø Directory already exists. Use --force to overwrite.", - ), - ); - const files = await fs.readdir(localPath); - console.log( - chalk.gray( - `Existing files: ${files.slice(0, 5).join(", ")}${files.length > 5 ? "..." : ""}`, - ), - ); - return; - } - - // Clear directory for force re-initialization - if (existsAlready && options.force) { - // Get info about what we're clearing (for logging) - const dirInfo = await getDirectoryInfo(localPath); - - console.log(chalk.yellow("šŸ—‘ļø Clearing existing directory...")); - console.log( - chalk.gray( - ` Removing: ${dirInfo.files} files, ${dirInfo.directories} dirs, ${dirInfo.symlinks} symlinks`, - ), - ); - - if (dirInfo.symlinks > 0) { + const result = await initDocset(docsetId, docset, configPath, { + force: options.force, + onSourceProgress: (sourceResult: SourceResult) => { + const icon = + sourceResult.type === "git_repo" + ? "Copied" + : sourceResult.type === "local_folder" + ? "Created symlinks:" + : "Extracted"; console.log( - chalk.gray( - " āš ļø Note: Symlinks will be removed, but source files are preserved", - ), + chalk.green(` āœ… ${icon} — ${sourceResult.message}`), ); - } - - // Safely clear directory (preserves source files for symlinked folders) - await safelyClearDirectory(localPath); - } + }, + }); - // Create target directory - await fs.mkdir(localPath, { recursive: true }); - - let totalFiles = 0; - const allDiscoveredPaths: string[] = []; - - // Process each source - for (const [index, source] of docset.sources.entries()) { + if (result.alreadyInitialized) { console.log( chalk.yellow( - `\nšŸ”„ Loading source ${index + 1}/${docset.sources.length}: ${source.type === "git_repo" ? source.url : source.paths?.join(", ")}`, + "āš ļø Directory already exists and is initialized. Use --force to overwrite.", ), ); - - if (source.type === "git_repo") { - // Use GitRepoLoader for all Git operations (REQ-19) - const loader = new GitRepoLoader(); - - console.log( - chalk.gray(` Using GitRepoLoader for smart content filtering`), - ); - - const webSourceConfig = { - url: source.url, - type: WebSourceType.GIT_REPO, - options: { - branch: source.branch || "main", - paths: source.paths || [], - }, - }; - - // Validate configuration - const validation = loader.validateConfig(webSourceConfig); - if (validation !== true) { - throw new Error( - `Invalid Git repository configuration: ${validation}`, - ); - } - - // Load content using GitRepoLoader - const result = await loader.load(webSourceConfig, localPath); - - if (!result.success) { - throw new Error(`Git repository loading failed: ${result.error}`); - } - - // Collect discovered paths for config update - allDiscoveredPaths.push(...result.files); - - totalFiles += result.files.length; - console.log( - chalk.green( - ` āœ… Copied ${result.files.length} files using smart filtering`, - ), - ); - - // Create source metadata - const metadata = { - source_url: source.url, - source_type: source.type, - downloaded_at: new Date().toISOString(), - files_count: result.files.length, - files: result.files, - docset_id: docsetId, - content_hash: result.contentHash, - }; - - await fs.writeFile( - path.join(localPath, `.agentic-source-${index}.json`), - JSON.stringify(metadata, null, 2), - ); - } else if (source.type === "local_folder") { - // Handle local folder initialization - console.log(chalk.gray(` Creating symlinks for local folder`)); - - if (!source.paths || source.paths.length === 0) { - throw new Error(`Local folder source has no paths configured`); - } - - // Import symlink utilities - const { createSymlinks } = await import("@codemcp/knowledge-core"); - - // Note: directory is already cleared above if --force is used, - // so no need to call removeSymlinks here - - const configDir = path.dirname(configPath); - const projectRoot = path.dirname(configDir); - - // Verify source paths exist - const validatedPaths: string[] = []; - for (const sourcePath of source.paths) { - const absolutePath = path.isAbsolute(sourcePath) - ? sourcePath - : path.resolve(projectRoot, sourcePath); - - try { - const stat = await fs.stat(absolutePath); - if (!stat.isDirectory()) { - throw new Error(`Path is not a directory: ${sourcePath}`); - } - validatedPaths.push(sourcePath); - } catch { - throw new Error( - `Local folder path does not exist: ${sourcePath}`, - ); - } - } - - // Create symlinks - await createSymlinks(validatedPaths, localPath, projectRoot); - - console.log( - chalk.green(` āœ… Created ${validatedPaths.length} symlink(s)`), - ); - - // Count files in symlinked directories for metadata - let fileCount = 0; - const files: string[] = []; - - async function countFilesRecursive(dir: string): Promise { - const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - await countFilesRecursive(fullPath); - } else if (entry.isFile()) { - fileCount++; - files.push(path.relative(localPath, fullPath)); - } - } - } - - await countFilesRecursive(localPath); - totalFiles += fileCount; - - // Create source metadata - const metadata = { - source_paths: validatedPaths, - source_type: source.type, - initialized_at: new Date().toISOString(), - files_count: fileCount, - files: files, - docset_id: docsetId, - }; - - await fs.writeFile( - path.join(localPath, `.agentic-source-${index}.json`), - JSON.stringify(metadata, null, 2), - ); - } else if (source.type === "archive") { - // Handle archive file initialization (zip, tar.gz, etc.) - const loader = new ArchiveLoader(); - const sourceUrl = source.url || source.path || ""; - - console.log( - chalk.gray(` Using ArchiveLoader for archive extraction`), - ); - - const webSourceConfig = { - url: sourceUrl, - type: WebSourceType.ARCHIVE, - options: { - paths: source.paths || [], - }, - }; - - // Validate configuration - const validation = loader.validateConfig(webSourceConfig); - if (validation !== true) { - throw new Error( - `Invalid archive source configuration: ${validation}`, - ); - } - - // Load content using ArchiveLoader - const result = await loader.load(webSourceConfig, localPath); - - if (!result.success) { - throw new Error(`Archive loading failed: ${result.error}`); - } - - // Collect discovered paths for config update - allDiscoveredPaths.push(...result.files); - - totalFiles += result.files.length; - console.log( - chalk.green( - ` āœ… Extracted ${result.files.length} files from archive`, - ), - ); - - // Create source metadata - const metadata = { - source_url: sourceUrl, - source_type: source.type, - downloaded_at: new Date().toISOString(), - files_count: result.files.length, - files: result.files, - docset_id: docsetId, - content_hash: result.contentHash, - }; - - await fs.writeFile( - path.join(localPath, `.agentic-source-${index}.json`), - JSON.stringify(metadata, null, 2), - ); - } else { - console.log( - chalk.red( - ` āŒ Source type '${(source as any).type}' not yet supported`, - ), - ); - } + return; } - // Create overall metadata - const overallMetadata = { - docset_id: docsetId, - docset_name: docset.name, - initialized_at: new Date().toISOString(), - total_files: totalFiles, - sources_count: docset.sources.length, - }; - - await fs.writeFile( - path.join(localPath, ".agentic-metadata.json"), - JSON.stringify(overallMetadata, null, 2), - ); - // Update configuration with discovered paths (only if --discover-paths flag used) - if (allDiscoveredPaths.length > 0 && options.discoverPaths) { + const allFiles = result.sourceResults.flatMap((r) => r.files); + if (allFiles.length > 0 && options.discoverPaths) { console.log( chalk.yellow( `\nšŸ“ Discovering directory patterns from extracted files...`, ), ); - // Convert file list to directory patterns - const directoryPatterns = - discoverDirectoryPatterns(allDiscoveredPaths); + const directoryPatterns = discoverDirectoryPatterns(allFiles); console.log( chalk.gray( - ` Found ${allDiscoveredPaths.length} files → ${directoryPatterns.length} patterns`, + ` Found ${allFiles.length} files → ${directoryPatterns.length} patterns`, ), ); @@ -384,8 +111,8 @@ export const initCommand = new Command("init") console.log( chalk.green(`\nšŸŽ‰ Successfully initialized docset '${docsetId}'`), ); - console.log(chalk.gray(`šŸ“ Location: ${localPath}`)); - console.log(chalk.gray(`šŸ“„ Total files: ${totalFiles}`)); + console.log(chalk.gray(`šŸ“ Location: ${result.localPath}`)); + console.log(chalk.gray(`šŸ“„ Total files: ${result.totalFiles}`)); console.log( chalk.gray(`šŸ”— Sources processed: ${docset.sources.length}`), ); diff --git a/packages/content-loader/package.json b/packages/content-loader/package.json index 7cd355b..9db97ab 100644 --- a/packages/content-loader/package.json +++ b/packages/content-loader/package.json @@ -29,6 +29,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@codemcp/knowledge-core": "workspace:*", "adm-zip": "0.5.16", "simple-git": "^3.22.0", "tar": "7.5.9" diff --git a/packages/content-loader/src/docset-init.ts b/packages/content-loader/src/docset-init.ts new file mode 100644 index 0000000..8738196 --- /dev/null +++ b/packages/content-loader/src/docset-init.ts @@ -0,0 +1,286 @@ +/** + * Shared docset initialization logic used by both the CLI and MCP server. + */ + +import { promises as fs } from "node:fs"; +import { existsSync } from "node:fs"; +import * as path from "node:path"; +import { + calculateLocalPath, + safelyClearDirectory, + createSymlinks, + type DocsetConfig, +} from "@codemcp/knowledge-core"; +import { GitRepoLoader } from "./content/git-repo-loader.js"; +import { ArchiveLoader } from "./content/archive-loader.js"; +import { WebSourceType } from "./types.js"; + +export interface SourceResult { + index: number; + type: string; + filesCount: number; + files: string[]; + message: string; + contentHash?: string; +} + +export interface InitDocsetResult { + localPath: string; + totalFiles: number; + sourceResults: SourceResult[]; + /** True when already initialized and force was not set */ + alreadyInitialized: boolean; +} + +export interface InitDocsetOptions { + force?: boolean; + /** Called after each source is processed so callers can show progress */ + onSourceProgress?: (result: SourceResult) => void; +} + +/** + * Initialize the sources for a docset: download / symlink content, write + * metadata files. Pure logic — no console output, no config loading. + * + * @param docsetId Docset identifier (used in metadata) + * @param docset Already-resolved DocsetConfig + * @param configPath Absolute path to the `.knowledge/config.yaml` file + * @param options Optional flags and progress callback + */ +export async function initDocset( + docsetId: string, + docset: DocsetConfig, + configPath: string, + options: InitDocsetOptions = {}, +): Promise { + const { force = false, onSourceProgress } = options; + + if (!docset.sources || docset.sources.length === 0) { + throw new Error(`Docset '${docsetId}' has no sources configured`); + } + + const localPath = calculateLocalPath(docset, configPath); + + // Check if already initialized + let existsAlready = false; + try { + const stat = await fs.stat(localPath); + if (stat.isDirectory()) existsAlready = true; + } catch { + // Directory doesn't exist yet — that's fine + } + + if (existsAlready && !force) { + const metadataPath = path.join(localPath, ".agentic-metadata.json"); + if (existsSync(metadataPath)) { + return { + localPath, + totalFiles: 0, + sourceResults: [], + alreadyInitialized: true, + }; + } + } + + if (existsAlready && force) { + await safelyClearDirectory(localPath); + } + + await fs.mkdir(localPath, { recursive: true }); + + const configDir = path.dirname(configPath); + const projectRoot = path.dirname(configDir); + + let totalFiles = 0; + const sourceResults: SourceResult[] = []; + + for (const [index, source] of docset.sources.entries()) { + let result: SourceResult; + + if (source.type === "git_repo") { + const loader = new GitRepoLoader(); + const webSourceConfig = { + url: source.url, + type: WebSourceType.GIT_REPO, + options: { + branch: source.branch || "main", + paths: source.paths || [], + }, + }; + + const validation = loader.validateConfig(webSourceConfig); + if (validation !== true) { + throw new Error(`Invalid Git repository configuration: ${validation}`); + } + + const loadResult = await loader.load(webSourceConfig, localPath); + if (!loadResult.success) { + throw new Error(`Git repository loading failed: ${loadResult.error}`); + } + + result = { + index, + type: "git_repo", + filesCount: loadResult.files.length, + files: loadResult.files, + message: `${loadResult.files.length} files loaded from ${source.url}`, + contentHash: loadResult.contentHash, + }; + + await fs.writeFile( + path.join(localPath, `.agentic-source-${index}.json`), + JSON.stringify( + { + source_url: source.url, + source_type: source.type, + downloaded_at: new Date().toISOString(), + files_count: loadResult.files.length, + files: loadResult.files, + docset_id: docsetId, + content_hash: loadResult.contentHash, + }, + null, + 2, + ), + ); + } else if (source.type === "local_folder") { + if (!source.paths || source.paths.length === 0) { + throw new Error( + `Local folder source ${index + 1} has no paths configured`, + ); + } + + const validatedPaths: string[] = []; + for (const sourcePath of source.paths) { + const absolutePath = path.isAbsolute(sourcePath) + ? sourcePath + : path.resolve(projectRoot, sourcePath); + try { + const stat = await fs.stat(absolutePath); + if (!stat.isDirectory()) { + throw new Error(`Path is not a directory: ${sourcePath}`); + } + validatedPaths.push(sourcePath); + } catch { + throw new Error(`Local folder path does not exist: ${sourcePath}`); + } + } + + await createSymlinks(validatedPaths, localPath, projectRoot); + + let fileCount = 0; + const files: string[] = []; + async function countFiles(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await countFiles(fullPath); + } else if (entry.isFile()) { + fileCount++; + files.push(path.relative(localPath, fullPath)); + } + } + } + await countFiles(localPath); + + result = { + index, + type: "local_folder", + filesCount: fileCount, + files, + message: `${validatedPaths.length} symlink(s) created, ${fileCount} files accessible`, + }; + + await fs.writeFile( + path.join(localPath, `.agentic-source-${index}.json`), + JSON.stringify( + { + source_paths: validatedPaths, + source_type: source.type, + initialized_at: new Date().toISOString(), + files_count: fileCount, + files, + docset_id: docsetId, + }, + null, + 2, + ), + ); + } else if (source.type === "archive") { + const loader = new ArchiveLoader(); + const sourceUrl = source.url || source.path || ""; + const webSourceConfig = { + url: sourceUrl, + type: WebSourceType.ARCHIVE, + options: { paths: source.paths || [] }, + }; + + const validation = loader.validateConfig(webSourceConfig); + if (validation !== true) { + throw new Error(`Invalid archive source configuration: ${validation}`); + } + + const loadResult = await loader.load(webSourceConfig, localPath); + if (!loadResult.success) { + throw new Error(`Archive loading failed: ${loadResult.error}`); + } + + result = { + index, + type: "archive", + filesCount: loadResult.files.length, + files: loadResult.files, + message: `${loadResult.files.length} files extracted from ${sourceUrl}`, + contentHash: loadResult.contentHash, + }; + + await fs.writeFile( + path.join(localPath, `.agentic-source-${index}.json`), + JSON.stringify( + { + source_url: sourceUrl, + source_type: source.type, + downloaded_at: new Date().toISOString(), + files_count: loadResult.files.length, + files: loadResult.files, + docset_id: docsetId, + content_hash: loadResult.contentHash, + }, + null, + 2, + ), + ); + } else { + result = { + index, + type: (source as { type: string }).type, + filesCount: 0, + files: [], + message: `source type '${(source as { type: string }).type}' not supported, skipped`, + }; + } + + totalFiles += result.filesCount; + sourceResults.push(result); + onSourceProgress?.(result); + } + + // Write the overall metadata file — this is what search_docs checks for + await fs.writeFile( + path.join(localPath, ".agentic-metadata.json"), + JSON.stringify( + { + docset_id: docsetId, + docset_name: docset.name, + initialized_at: new Date().toISOString(), + total_files: totalFiles, + sources_count: docset.sources.length, + }, + null, + 2, + ), + ); + + return { localPath, totalFiles, sourceResults, alreadyInitialized: false }; +} diff --git a/packages/content-loader/src/index.ts b/packages/content-loader/src/index.ts index 19b645f..c949a30 100644 --- a/packages/content-loader/src/index.ts +++ b/packages/content-loader/src/index.ts @@ -4,3 +4,9 @@ export * from "./types.js"; export * from "./content/index.js"; +export { initDocset } from "./docset-init.js"; +export type { + InitDocsetOptions, + InitDocsetResult, + SourceResult, +} from "./docset-init.js"; diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index f6f3523..75b6227 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -33,6 +33,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@codemcp/knowledge-content-loader": "workspace:*", "@codemcp/knowledge-core": "workspace:*", "@modelcontextprotocol/sdk": "^1.19.1" }, diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 302438a..57e9c53 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -16,10 +16,14 @@ import { createTemplateContext, getEffectiveTemplate, createStructuredResponse, + ConfigManager, + ensureKnowledgeGitignoreSync, type KnowledgeConfig, } from "@codemcp/knowledge-core"; +import { initDocset } from "@codemcp/knowledge-content-loader"; import { existsSync } from "node:fs"; import { resolve, dirname } from "node:path"; +import * as path from "node:path"; /** * Create an agentic knowledge MCP server @@ -159,6 +163,27 @@ After configuring, the tool will show available docsets here.`, additionalProperties: false, }, }, + { + name: "init_docset", + description: + "Initialize a docset by downloading and preparing its content sources. Run this when a docset is configured but not yet initialized.", + inputSchema: { + type: "object", + properties: { + docset_id: { + type: "string", + description: "The identifier of the docset to initialize.", + }, + force: { + type: "boolean", + description: + "Force re-initialization even if the docset already exists.", + }, + }, + required: ["docset_id"], + additionalProperties: false, + }, + }, ], }; } @@ -218,6 +243,30 @@ ${docsetInfo} additionalProperties: false, }, }, + { + name: "init_docset", + description: `Initialize a docset by downloading and preparing its content sources. Run this when a docset is configured but not yet initialized. + +šŸ“š **AVAILABLE DOCSETS TO INITIALIZE:** +${config.docsets.map((d) => `• **${d.id}** (${d.name})`).join("\n")}`, + inputSchema: { + type: "object", + properties: { + docset_id: { + type: "string", + description: "The identifier of the docset to initialize.", + enum: config.docsets.map((d) => d.id), + }, + force: { + type: "boolean", + description: + "Force re-initialization even if the docset already exists.", + }, + }, + required: ["docset_id"], + additionalProperties: false, + }, + }, ], }; }); @@ -440,6 +489,63 @@ ${docsetInfo} }; } + case "init_docset": { + const { docset_id, force = false } = args as { + docset_id: string; + force?: boolean; + }; + + if (!docset_id || typeof docset_id !== "string") { + throw new Error("docset_id is required and must be a string"); + } + + const configManager = new ConfigManager(); + const { config, configPath } = await configManager.loadConfig( + process.cwd(), + ); + + // Invalidate cache so the next search_docs call sees the new state + configCache = null; + configLoadTime = 0; + + ensureKnowledgeGitignoreSync(configPath); + + const docset = config.docsets.find((d) => d.id === docset_id); + if (!docset) { + throw new Error( + `Docset '${docset_id}' not found. Available: ${config.docsets.map((d) => d.id).join(", ")}`, + ); + } + + const result = await initDocset(docset_id, docset, configPath, { + force, + }); + + if (result.alreadyInitialized) { + return { + content: [ + { + type: "text", + text: `Docset '${docset_id}' is already initialized. Use force: true to re-initialize.`, + }, + ], + }; + } + + const summary = [ + `Successfully initialized docset '${docset_id}' (${docset.name}).`, + `Location: ${result.localPath}`, + `Total files: ${result.totalFiles}`, + ...result.sourceResults.map( + (r) => `Source ${r.index + 1} (${r.type}): ${r.message}`, + ), + ].join("\n"); + + return { + content: [{ type: "text", text: summary }], + }; + } + default: throw new Error(`Unknown tool: ${name}`); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88567c1..02728a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ importers: packages/content-loader: dependencies: + "@codemcp/knowledge-core": + specifier: workspace:* + version: link:../core adm-zip: specifier: 0.5.16 version: 0.5.16 @@ -197,6 +200,9 @@ importers: packages/mcp-server: dependencies: + "@codemcp/knowledge-content-loader": + specifier: workspace:* + version: link:../content-loader "@codemcp/knowledge-core": specifier: workspace:* version: link:../core From 72e4b77482f09124f67befb2401bf655cfd7cd97 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Mar 2026 06:30:41 +0000 Subject: [PATCH 2/3] fix(core): link source contents directly into docset dir (no extra subfolder) Fixes two related bugs in createSymlinks: 1. Path with no trailing slash (eg. /temp/demo): the directory name "demo" was appended as a subfolder, producing demo-docset/demo/ instead of exposing the contents at the docset root. 2. Path with trailing slash (eg. /temp/demo/): split("/").pop() returned an empty string, which fell back to the hardcoded name "unknown", producing demo-docset/unknown/. Root cause: the function symlinked the source directory itself, deriving the link name from the path string. Fix: iterate over the entries inside the source directory and create one symlink per entry directly inside targetDir. This matches the behaviour of git_repo and archive sources, which also populate the docset root without an extra named subfolder. The trailing-slash case is a non-issue because we now read directory contents rather than parsing the path string. https://claude.ai/code/session_014fPGYkZQTaWzSuLcDejZQD --- packages/content-loader/src/docset-init.ts | 14 +- packages/core/src/__tests__/cleanup.test.ts | 50 +++---- .../src/__tests__/local-folder-safety.test.ts | 125 +++++++----------- .../src/__tests__/symlink-cleanup.test.ts | 81 ++++-------- packages/core/src/__tests__/symlinks.test.ts | 103 +++++++-------- packages/core/src/paths/symlinks.ts | 28 ++-- packages/mcp-server/src/server.ts | 1 - test/e2e/mcp-protocol-compliance.test.ts | 3 +- 8 files changed, 155 insertions(+), 250 deletions(-) diff --git a/packages/content-loader/src/docset-init.ts b/packages/content-loader/src/docset-init.ts index 8738196..acab529 100644 --- a/packages/content-loader/src/docset-init.ts +++ b/packages/content-loader/src/docset-init.ts @@ -1,5 +1,5 @@ /** - * Shared docset initialization logic used by both the CLI and MCP server. + * Docset initialization logic shared between CLI and MCP server. */ import { promises as fs } from "node:fs"; @@ -39,13 +39,8 @@ export interface InitDocsetOptions { } /** - * Initialize the sources for a docset: download / symlink content, write - * metadata files. Pure logic — no console output, no config loading. - * - * @param docsetId Docset identifier (used in metadata) - * @param docset Already-resolved DocsetConfig - * @param configPath Absolute path to the `.knowledge/config.yaml` file - * @param options Optional flags and progress callback + * Download / symlink all sources for a docset and write metadata files. + * Does not load config or produce console output — callers handle both. */ export async function initDocset( docsetId: string, @@ -61,13 +56,12 @@ export async function initDocset( const localPath = calculateLocalPath(docset, configPath); - // Check if already initialized let existsAlready = false; try { const stat = await fs.stat(localPath); if (stat.isDirectory()) existsAlready = true; } catch { - // Directory doesn't exist yet — that's fine + // not yet created } if (existsAlready && !force) { diff --git a/packages/core/src/__tests__/cleanup.test.ts b/packages/core/src/__tests__/cleanup.test.ts index 624de96..c8d89f9 100644 --- a/packages/core/src/__tests__/cleanup.test.ts +++ b/packages/core/src/__tests__/cleanup.test.ts @@ -33,7 +33,6 @@ describe("Safe Directory Cleanup", () => { describe("safelyClearDirectory", () => { it("should clear directory with regular files", async () => { - // Create some files await fs.writeFile(path.join(targetDir, "file1.txt"), "content1"); await fs.writeFile(path.join(targetDir, "file2.txt"), "content2"); await fs.mkdir(path.join(targetDir, "subdir"), { recursive: true }); @@ -42,10 +41,8 @@ describe("Safe Directory Cleanup", () => { "content3", ); - // Clear directory await safelyClearDirectory(targetDir); - // Directory should not exist const exists = await fs .access(targetDir) .then(() => true) @@ -54,78 +51,63 @@ describe("Safe Directory Cleanup", () => { }); it("should handle non-existent directory gracefully", async () => { - const nonExistent = path.join(testDir, "does-not-exist"); - - // Should not throw - await expect(safelyClearDirectory(nonExistent)).resolves.not.toThrow(); + await expect( + safelyClearDirectory(path.join(testDir, "does-not-exist")), + ).resolves.not.toThrow(); }); it("should clear directory with symlinks without deleting source files", async () => { - // Create source file const srcFolder = path.join(sourceDir, "src"); await fs.mkdir(srcFolder, { recursive: true }); const sourceFile = path.join(srcFolder, "important.js"); await fs.writeFile(sourceFile, "IMPORTANT DATA"); - // Create symlink await createSymlinks(["src"], targetDir, sourceDir); - // Verify symlink exists - const symlinkPath = path.join(targetDir, "src"); - const stat = await fs.lstat(symlinkPath); - expect(stat.isSymbolicLink()).toBe(true); + const symlinkPath = path.join(targetDir, "important.js"); + expect((await fs.lstat(symlinkPath)).isSymbolicLink()).toBe(true); - // Clear target directory await safelyClearDirectory(targetDir); - // Source file must still exist! - const sourceContent = await fs.readFile(sourceFile, "utf-8"); - expect(sourceContent).toBe("IMPORTANT DATA"); - - // Target directory should be gone - const targetExists = await fs - .access(targetDir) - .then(() => true) - .catch(() => false); - expect(targetExists).toBe(false); + // Source must survive removal of the docset directory + expect(await fs.readFile(sourceFile, "utf-8")).toBe("IMPORTANT DATA"); + expect( + await fs + .access(targetDir) + .then(() => true) + .catch(() => false), + ).toBe(false); }); }); describe("containsSymlinks", () => { it("should detect symlinks", async () => { - // Create a source folder const srcFolder = path.join(sourceDir, "src"); await fs.mkdir(srcFolder, { recursive: true }); await fs.writeFile(path.join(srcFolder, "file.js"), "content"); - // Create symlink await createSymlinks(["src"], targetDir, sourceDir); - const hasSymlinks = await containsSymlinks(targetDir); - expect(hasSymlinks).toBe(true); + expect(await containsSymlinks(targetDir)).toBe(true); }); it("should return false for directory with no symlinks", async () => { await fs.writeFile(path.join(targetDir, "regular.txt"), "content"); - const hasSymlinks = await containsSymlinks(targetDir); - expect(hasSymlinks).toBe(false); + expect(await containsSymlinks(targetDir)).toBe(false); }); it("should return false for non-existent directory", async () => { - const hasSymlinks = await containsSymlinks(path.join(testDir, "nope")); - expect(hasSymlinks).toBe(false); + expect(await containsSymlinks(path.join(testDir, "nope"))).toBe(false); }); }); describe("getDirectoryInfo", () => { it("should count different entry types", async () => { - // Create mixed content await fs.writeFile(path.join(targetDir, "file1.txt"), "content"); await fs.writeFile(path.join(targetDir, "file2.txt"), "content"); await fs.mkdir(path.join(targetDir, "subdir"), { recursive: true }); - // Create symlink const srcFolder = path.join(sourceDir, "src"); await fs.mkdir(srcFolder, { recursive: true }); await fs.writeFile(path.join(srcFolder, "file.js"), "content"); diff --git a/packages/core/src/__tests__/local-folder-safety.test.ts b/packages/core/src/__tests__/local-folder-safety.test.ts index 73a5d57..ced5716 100644 --- a/packages/core/src/__tests__/local-folder-safety.test.ts +++ b/packages/core/src/__tests__/local-folder-safety.test.ts @@ -15,7 +15,6 @@ describe("Local Folder Cleanup Safety", () => { let sourceFile: string; beforeEach(async () => { - // Create test directories testDir = path.join(tmpdir(), `agentic-safety-test-${Date.now()}`); sourceDir = path.join(testDir, "source"); targetDir = path.join(testDir, "target"); @@ -23,7 +22,6 @@ describe("Local Folder Cleanup Safety", () => { await fs.mkdir(sourceDir, { recursive: true }); await fs.mkdir(targetDir, { recursive: true }); - // Create a source directory with actual files const actualSourceFolder = path.join(sourceDir, "src"); await fs.mkdir(actualSourceFolder, { recursive: true }); sourceFile = path.join(actualSourceFolder, "important-file.js"); @@ -35,34 +33,26 @@ describe("Local Folder Cleanup Safety", () => { }); it("CRITICAL: should NOT delete source files when removing symlinks", async () => { - // Create symlink to source directory await createSymlinks(["src"], targetDir, sourceDir); - // Verify symlink was created - const symlinkPath = path.join(targetDir, "src"); - const linkStat = await fs.lstat(symlinkPath); - expect(linkStat.isSymbolicLink()).toBe(true); - - // Verify we can access the source file through the symlink - const fileViaSymlink = path.join(symlinkPath, "important-file.js"); - const content = await fs.readFile(fileViaSymlink, "utf-8"); - expect(content).toBe("CRITICAL DATA - DO NOT DELETE"); + const symlinkPath = path.join(targetDir, "important-file.js"); + expect((await fs.lstat(symlinkPath)).isSymbolicLink()).toBe(true); + expect(await fs.readFile(symlinkPath, "utf-8")).toBe( + "CRITICAL DATA - DO NOT DELETE", + ); - // Remove symlinks await removeSymlinks(targetDir); - // CRITICAL: Source file must still exist! + // CRITICAL: removing the symlink must never touch the source const stillExists = await fs .access(sourceFile) .then(() => true) .catch(() => false); expect(stillExists).toBe(true); + expect(await fs.readFile(sourceFile, "utf-8")).toBe( + "CRITICAL DATA - DO NOT DELETE", + ); - // Verify content is unchanged - const originalContent = await fs.readFile(sourceFile, "utf-8"); - expect(originalContent).toBe("CRITICAL DATA - DO NOT DELETE"); - - // Symlink should be gone const symlinkGone = await fs .lstat(symlinkPath) .then(() => false) @@ -71,107 +61,88 @@ describe("Local Folder Cleanup Safety", () => { }); it("CRITICAL: should NOT delete source files when clearing target directory", async () => { - // Create symlink await createSymlinks(["src"], targetDir, sourceDir); - // Verify source file exists - expect(await fs.readFile(sourceFile, "utf-8")).toBe( - "CRITICAL DATA - DO NOT DELETE", - ); - - // Simulate clearing target directory (what --force does) - // This is the DANGEROUS operation we need to test + // Simulate --force: delete the whole docset directory await fs.rm(targetDir, { recursive: true, force: true }); - // CRITICAL: Source file must STILL exist after removing target! + // CRITICAL: fs.rm must not follow symlinks into the source const stillExists = await fs .access(sourceFile) .then(() => true) .catch(() => false); - expect(stillExists).toBe( true, "CRITICAL FAILURE: Source file was deleted!", ); if (stillExists) { - const content = await fs.readFile(sourceFile, "utf-8"); - expect(content).toBe("CRITICAL DATA - DO NOT DELETE"); + expect(await fs.readFile(sourceFile, "utf-8")).toBe( + "CRITICAL DATA - DO NOT DELETE", + ); } }); it("CRITICAL: should handle nested symlinks safely", async () => { - // Create nested structure in source const nestedDir = path.join(sourceDir, "src", "nested"); await fs.mkdir(nestedDir, { recursive: true }); const nestedFile = path.join(nestedDir, "nested-file.js"); await fs.writeFile(nestedFile, "NESTED CRITICAL DATA"); - // Create symlink await createSymlinks(["src"], targetDir, sourceDir); - - // Clear target directory await fs.rm(targetDir, { recursive: true, force: true }); - // CRITICAL: All source files must still exist - const sourceExists = await fs - .access(sourceFile) - .then(() => true) - .catch(() => false); - const nestedExists = await fs - .access(nestedFile) - .then(() => true) - .catch(() => false); - - expect(sourceExists).toBe(true, "Source file was deleted!"); - expect(nestedExists).toBe(true, "Nested source file was deleted!"); + // CRITICAL: All source files must survive target removal + expect( + await fs + .access(sourceFile) + .then(() => true) + .catch(() => false), + ).toBe(true, "Source file was deleted!"); + expect( + await fs + .access(nestedFile) + .then(() => true) + .catch(() => false), + ).toBe(true, "Nested source file was deleted!"); }); it("should safely handle mixed content (symlinks and regular files)", async () => { - // Create symlink await createSymlinks(["src"], targetDir, sourceDir); - // Add a regular file to target directory const regularFile = path.join(targetDir, "regular-file.txt"); await fs.writeFile(regularFile, "This can be deleted"); - // Clear target directory await fs.rm(targetDir, { recursive: true, force: true }); - // Source file must still exist - const sourceExists = await fs - .access(sourceFile) - .then(() => true) - .catch(() => false); - expect(sourceExists).toBe(true); - - // Target directory should be gone - const targetExists = await fs - .access(targetDir) - .then(() => true) - .catch(() => false); - expect(targetExists).toBe(false); + expect( + await fs + .access(sourceFile) + .then(() => true) + .catch(() => false), + ).toBe(true); + expect( + await fs + .access(targetDir) + .then(() => true) + .catch(() => false), + ).toBe(false); }); it("should document Node.js symlink behavior", async () => { - // This test documents how Node.js handles symlinks with fs.rm - // According to Node.js docs, fs.rm should NOT follow symlinks - + // fs.rm with recursive:true must NOT follow symlinks — this test pins that contract. await createSymlinks(["src"], targetDir, sourceDir); - const symlinkPath = path.join(targetDir, "src"); + const symlinkPath = path.join(targetDir, "important-file.js"); - // Verify it's a symlink - const stats = await fs.lstat(symlinkPath); - expect(stats.isSymbolicLink()).toBe(true); + expect((await fs.lstat(symlinkPath)).isSymbolicLink()).toBe(true); - // Remove just the symlink using fs.unlink await fs.unlink(symlinkPath); - // Source should still exist - const sourceExists = await fs - .access(sourceFile) - .then(() => true) - .catch(() => false); - expect(sourceExists).toBe(true); + expect( + await fs + .access(sourceFile) + .then(() => true) + .catch(() => false), + ).toBe(true); }); }); diff --git a/packages/core/src/__tests__/symlink-cleanup.test.ts b/packages/core/src/__tests__/symlink-cleanup.test.ts index 659cdc6..7866a03 100644 --- a/packages/core/src/__tests__/symlink-cleanup.test.ts +++ b/packages/core/src/__tests__/symlink-cleanup.test.ts @@ -1,5 +1,5 @@ /** - * Symlink Cleanup Tests - Force re-init should remove orphaned symlinks + * Symlink cleanup tests — force re-init should remove orphaned symlinks */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; @@ -14,7 +14,6 @@ describe("Symlink Cleanup on Force Re-init", () => { let targetDir: string; beforeEach(async () => { - // Create test directories testDir = path.join(tmpdir(), `agentic-symlink-test-${Date.now()}`); sourceDir = path.join(testDir, "source"); targetDir = path.join(testDir, "target"); @@ -22,12 +21,11 @@ describe("Symlink Cleanup on Force Re-init", () => { await fs.mkdir(sourceDir, { recursive: true }); await fs.mkdir(targetDir, { recursive: true }); - // Create source directories + // Each source subdirectory has one distinct file so assertions are unambiguous. await fs.mkdir(path.join(sourceDir, "src"), { recursive: true }); await fs.mkdir(path.join(sourceDir, "lib"), { recursive: true }); await fs.mkdir(path.join(sourceDir, "docs"), { recursive: true }); - // Add some files to make them real directories await fs.writeFile(path.join(sourceDir, "src", "index.js"), "content"); await fs.writeFile(path.join(sourceDir, "lib", "utils.js"), "content"); await fs.writeFile(path.join(sourceDir, "docs", "README.md"), "content"); @@ -39,125 +37,98 @@ describe("Symlink Cleanup on Force Re-init", () => { describe("removeSymlinks function", () => { it("should remove all symlinks from target directory", async () => { - // Create symlinks await createSymlinks(["src", "lib"], targetDir, sourceDir); - // Verify symlinks exist - const linksStat1 = await fs.lstat(path.join(targetDir, "src")); - const linksStat2 = await fs.lstat(path.join(targetDir, "lib")); - expect(linksStat1.isSymbolicLink()).toBe(true); - expect(linksStat2.isSymbolicLink()).toBe(true); + expect( + (await fs.lstat(path.join(targetDir, "index.js"))).isSymbolicLink(), + ).toBe(true); + expect( + (await fs.lstat(path.join(targetDir, "utils.js"))).isSymbolicLink(), + ).toBe(true); - // Remove all symlinks await removeSymlinks(targetDir); - // Verify symlinks are gone const files = await fs.readdir(targetDir); expect(files).toHaveLength(0); }); it("should not remove regular files or directories", async () => { - // Create a mix of symlinks and regular files await createSymlinks(["src"], targetDir, sourceDir); await fs.writeFile(path.join(targetDir, "regular-file.txt"), "content"); await fs.mkdir(path.join(targetDir, "regular-dir"), { recursive: true }); - // Remove symlinks await removeSymlinks(targetDir); - // Regular files should still exist const files = await fs.readdir(targetDir); expect(files).toContain("regular-file.txt"); expect(files).toContain("regular-dir"); - expect(files).not.toContain("src"); + expect(files).not.toContain("index.js"); }); }); describe("createSymlinks with cleanup", () => { it("should remove orphaned symlinks when paths change", async () => { - // Initial: create symlinks for src, lib, docs await createSymlinks(["src", "lib", "docs"], targetDir, sourceDir); let files = await fs.readdir(targetDir); expect(files).toHaveLength(3); - expect(files).toContain("src"); - expect(files).toContain("lib"); - expect(files).toContain("docs"); + expect(files).toContain("index.js"); + expect(files).toContain("utils.js"); + expect(files).toContain("README.md"); - // Simulate force re-init with different paths (only src) - // This should: - // 1. Remove ALL existing symlinks - // 2. Create new symlinks only for specified paths - - await removeSymlinks(targetDir); // Should be called before createSymlinks + await removeSymlinks(targetDir); await createSymlinks(["src"], targetDir, sourceDir); - // Only src should exist now files = await fs.readdir(targetDir); expect(files).toHaveLength(1); - expect(files).toContain("src"); - expect(files).not.toContain("lib"); - expect(files).not.toContain("docs"); + expect(files).toContain("index.js"); + expect(files).not.toContain("utils.js"); + expect(files).not.toContain("README.md"); }); it("should handle empty target directory gracefully", async () => { - // Calling removeSymlinks on empty directory should not error await expect(removeSymlinks(targetDir)).resolves.not.toThrow(); - // Should be able to create symlinks after await createSymlinks(["src"], targetDir, sourceDir); const files = await fs.readdir(targetDir); expect(files).toHaveLength(1); - expect(files).toContain("src"); + expect(files).toContain("index.js"); }); it("should update symlinks when source paths change", async () => { - // Create initial symlinks await createSymlinks(["src", "lib"], targetDir, sourceDir); - // Verify initial state let files = await fs.readdir(targetDir); - expect(files).toContain("src"); - expect(files).toContain("lib"); + expect(files).toContain("index.js"); + expect(files).toContain("utils.js"); - // Change configuration (remove lib, add docs) await removeSymlinks(targetDir); await createSymlinks(["src", "docs"], targetDir, sourceDir); - // Verify updated state files = await fs.readdir(targetDir); expect(files).toHaveLength(2); - expect(files).toContain("src"); - expect(files).toContain("docs"); - expect(files).not.toContain("lib"); // Orphaned symlink removed + expect(files).toContain("index.js"); + expect(files).toContain("README.md"); + expect(files).not.toContain("utils.js"); }); }); describe("Integration with force re-init", () => { it("should demonstrate the complete workflow", async () => { - // Step 1: Initial initialization with paths: ["src", "lib"] await createSymlinks(["src", "lib"], targetDir, sourceDir); let files = await fs.readdir(targetDir); - expect(files).toEqual(expect.arrayContaining(["src", "lib"])); + expect(files).toEqual(expect.arrayContaining(["index.js", "utils.js"])); - // Step 2: User changes config to paths: ["src", "docs"] - // Step 3: User runs init --force - - // The force re-init should: - // a) Remove all existing symlinks await removeSymlinks(targetDir); - - // b) Create new symlinks based on current config await createSymlinks(["src", "docs"], targetDir, sourceDir); - // Step 4: Verify final state files = await fs.readdir(targetDir); expect(files).toHaveLength(2); - expect(files).toContain("src"); - expect(files).toContain("docs"); - expect(files).not.toContain("lib"); // Properly cleaned up + expect(files).toContain("index.js"); + expect(files).toContain("README.md"); + expect(files).not.toContain("utils.js"); }); }); }); diff --git a/packages/core/src/__tests__/symlinks.test.ts b/packages/core/src/__tests__/symlinks.test.ts index ade5334..1d9dcf8 100644 --- a/packages/core/src/__tests__/symlinks.test.ts +++ b/packages/core/src/__tests__/symlinks.test.ts @@ -28,11 +28,9 @@ describe("Symlink Management", () => { sourceDir = join(tempDir, "docs"); targetDir = join(tempDir, ".knowledge", "docsets", "test-docs"); - // Create directory structure await fs.mkdir(join(tempDir, ".knowledge"), { recursive: true }); await fs.mkdir(sourceDir, { recursive: true }); - // Create some test files await fs.writeFile(join(sourceDir, "README.md"), "# Test Documentation"); await fs.writeFile(join(sourceDir, "guide.md"), "# User Guide"); }); @@ -42,67 +40,67 @@ describe("Symlink Management", () => { }); describe("createSymlinks", () => { - test("should create symlinks for local paths", async () => { - const sourcePaths = ["./docs"]; + test("should link source contents directly into targetDir (no extra subfolder)", async () => { + await createSymlinks(["./docs"], targetDir, projectRoot); + + const readme = join(targetDir, "README.md"); + const guide = join(targetDir, "guide.md"); - await createSymlinks(sourcePaths, targetDir, projectRoot); + expect((await fs.lstat(readme)).isSymbolicLink()).toBe(true); + expect((await fs.lstat(guide)).isSymbolicLink()).toBe(true); + + expect(await fs.readlink(readme)).toBe(join(sourceDir, "README.md")); + expect(await fs.readlink(guide)).toBe(join(sourceDir, "guide.md")); + }); - // Check that symlink was created - const symlinkPath = join(targetDir, "docs"); - const stats = await fs.lstat(symlinkPath); - expect(stats.isSymbolicLink()).toBe(true); + test("should handle trailing slash in source path", async () => { + await createSymlinks([sourceDir + "/"], targetDir, projectRoot); - // Check that symlink points to correct target - const target = await fs.readlink(symlinkPath); - expect(target).toBe(sourceDir); + expect( + (await fs.lstat(join(targetDir, "README.md"))).isSymbolicLink(), + ).toBe(true); + await expect(fs.access(join(targetDir, "unknown"))).rejects.toThrow(); }); - test("should create symlinks for multiple paths", async () => { - // Create additional source directory + test("should merge contents of multiple source paths into targetDir", async () => { const sourceDir2 = join(tempDir, "guides"); await fs.mkdir(sourceDir2); await fs.writeFile(join(sourceDir2, "tutorial.md"), "# Tutorial"); - const sourcePaths = ["./docs", "./guides"]; - - await createSymlinks(sourcePaths, targetDir, projectRoot); - - // Check both symlinks exist - const symlink1 = join(targetDir, "docs"); - const symlink2 = join(targetDir, "guides"); - - expect((await fs.lstat(symlink1)).isSymbolicLink()).toBe(true); - expect((await fs.lstat(symlink2)).isSymbolicLink()).toBe(true); + await createSymlinks(["./docs", "./guides"], targetDir, projectRoot); + + expect( + (await fs.lstat(join(targetDir, "README.md"))).isSymbolicLink(), + ).toBe(true); + expect( + (await fs.lstat(join(targetDir, "guide.md"))).isSymbolicLink(), + ).toBe(true); + expect( + (await fs.lstat(join(targetDir, "tutorial.md"))).isSymbolicLink(), + ).toBe(true); }); - test("should handle absolute paths", async () => { - const sourcePaths = [sourceDir]; - - await createSymlinks(sourcePaths, targetDir, projectRoot); + test("should handle absolute source paths", async () => { + await createSymlinks([sourceDir], targetDir, projectRoot); - const symlinkPath = join(targetDir, "docs"); - expect((await fs.lstat(symlinkPath)).isSymbolicLink()).toBe(true); + expect( + (await fs.lstat(join(targetDir, "README.md"))).isSymbolicLink(), + ).toBe(true); }); test("should throw error for non-existent source", async () => { - const sourcePaths = ["./non-existent"]; - await expect( - createSymlinks(sourcePaths, targetDir, projectRoot), + createSymlinks(["./non-existent"], targetDir, projectRoot), ).rejects.toThrow("Source path does not exist"); }); - test("should replace existing symlinks", async () => { - const sourcePaths = ["./docs"]; - - // Create initial symlink - await createSymlinks(sourcePaths, targetDir, projectRoot); - - // Create again (should replace) - await createSymlinks(sourcePaths, targetDir, projectRoot); + test("should replace existing symlinks on re-run", async () => { + await createSymlinks(["./docs"], targetDir, projectRoot); + await createSymlinks(["./docs"], targetDir, projectRoot); - const symlinkPath = join(targetDir, "docs"); - expect((await fs.lstat(symlinkPath)).isSymbolicLink()).toBe(true); + expect( + (await fs.lstat(join(targetDir, "README.md"))).isSymbolicLink(), + ).toBe(true); }); }); @@ -117,7 +115,6 @@ describe("Symlink Management", () => { test("should return false for broken symlinks", async () => { await createSymlinks(["./docs"], targetDir, projectRoot); - // Remove source directory to break symlink await fs.rm(sourceDir, { recursive: true }); const isValid = await validateSymlinks(targetDir); @@ -134,15 +131,10 @@ describe("Symlink Management", () => { test("should remove all symlinks in directory", async () => { await createSymlinks(["./docs"], targetDir, projectRoot); - // Verify symlink exists - const symlinkPath = join(targetDir, "docs"); - expect((await fs.lstat(symlinkPath)).isSymbolicLink()).toBe(true); - - // Remove symlinks await removeSymlinks(targetDir); - // Verify symlink is gone - await expect(fs.access(symlinkPath)).rejects.toThrow(); + await expect(fs.access(join(targetDir, "README.md"))).rejects.toThrow(); + await expect(fs.access(join(targetDir, "guide.md"))).rejects.toThrow(); }); test("should handle non-existent directory gracefully", async () => { @@ -167,12 +159,10 @@ describe("Symlink Management", () => { const result = await calculateLocalPathWithSymlinks(docset, configPath); - // Should return relative path to symlink directory expect(result).toBe(".knowledge/docsets/test-docs"); - - // Verify symlink was created - const symlinkPath = join(targetDir, "docs"); - expect((await fs.lstat(symlinkPath)).isSymbolicLink()).toBe(true); + expect( + (await fs.lstat(join(targetDir, "README.md"))).isSymbolicLink(), + ).toBe(true); }); test("should handle git_repo sources without symlinks", async () => { @@ -189,7 +179,6 @@ describe("Symlink Management", () => { const result = await calculateLocalPathWithSymlinks(docset, configPath); - // Should return absolute path for git repos const expected = join(tempDir, ".knowledge", "docsets", "git-docs"); expect(result).toBe(expected); }); diff --git a/packages/core/src/paths/symlinks.ts b/packages/core/src/paths/symlinks.ts index bc2a45c..95b14fc 100644 --- a/packages/core/src/paths/symlinks.ts +++ b/packages/core/src/paths/symlinks.ts @@ -18,35 +18,34 @@ export async function createSymlinks( projectRoot: string, ): Promise { try { - // Ensure target directory exists await fs.mkdir(targetDir, { recursive: true }); for (const sourcePath of sourcePaths) { - // Resolve source path to absolute const absoluteSourcePath = isAbsolute(sourcePath) ? sourcePath : resolve(projectRoot, sourcePath); - // Check if source exists try { await fs.access(absoluteSourcePath); } catch { throw new Error(`Source path does not exist: ${absoluteSourcePath}`); } - // Determine target symlink path - const sourceName = sourcePath.split("/").pop() || "unknown"; - const symlinkPath = join(targetDir, sourceName); + // Link each entry inside the source directory directly into targetDir + // so that the docset root is flat — consistent with git_repo / archive. + const entries = await fs.readdir(absoluteSourcePath); + for (const entry of entries) { + const symlinkPath = join(targetDir, entry); + const entryAbsPath = join(absoluteSourcePath, entry); - // Remove existing symlink if it exists - try { - await fs.unlink(symlinkPath); - } catch { - // Ignore if doesn't exist - } + try { + await fs.unlink(symlinkPath); + } catch { + // ignore — entry doesn't exist yet + } - // Create symlink - await fs.symlink(absoluteSourcePath, symlinkPath); + await fs.symlink(entryAbsPath, symlinkPath); + } } } catch (error) { throw new KnowledgeError( @@ -70,7 +69,6 @@ export async function validateSymlinks(targetDir: string): Promise { if (entry.isSymbolicLink()) { const symlinkPath = join(targetDir, entry.name); - // Check if symlink target exists try { await fs.access(symlinkPath); } catch { diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 57e9c53..788080d 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -23,7 +23,6 @@ import { import { initDocset } from "@codemcp/knowledge-content-loader"; import { existsSync } from "node:fs"; import { resolve, dirname } from "node:path"; -import * as path from "node:path"; /** * Create an agentic knowledge MCP server diff --git a/test/e2e/mcp-protocol-compliance.test.ts b/test/e2e/mcp-protocol-compliance.test.ts index fe93393..bcafd10 100644 --- a/test/e2e/mcp-protocol-compliance.test.ts +++ b/test/e2e/mcp-protocol-compliance.test.ts @@ -56,11 +56,12 @@ describe("MCP Protocol Compliance E2E Tests", () => { const tools = await client.listTools(); expect(tools.tools).toBeDefined(); - expect(tools.tools).toHaveLength(2); + expect(tools.tools).toHaveLength(3); const toolNames = tools.tools.map((tool) => tool.name); expect(toolNames).toContain("search_docs"); expect(toolNames).toContain("list_docsets"); + expect(toolNames).toContain("init_docset"); }); }); From 8f8acd5484087ea8a656aa13f071cd97eaf08db8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20J=C3=A4gle?= Date: Tue, 10 Mar 2026 08:56:39 +0100 Subject: [PATCH 3/3] fix: remove instruction on how to handle un-initialized docsets the init tool should now be resolved automagically --- packages/mcp-server/src/__tests__/integration.test.ts | 5 +---- packages/mcp-server/src/server.ts | 10 ++-------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/mcp-server/src/__tests__/integration.test.ts b/packages/mcp-server/src/__tests__/integration.test.ts index 5f8fb2d..80d56ea 100644 --- a/packages/mcp-server/src/__tests__/integration.test.ts +++ b/packages/mcp-server/src/__tests__/integration.test.ts @@ -196,10 +196,7 @@ docsets: // Should return an error expect(result.isError).toBe(true); - expect(result.content[0].text).toContain("not initialized"); - expect(result.content[0].text).toContain( - "npx agentic-knowledge-mcp init", - ); + expect(result.content[0].text).toContain("been initialized"); }); }); }); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 788080d..a465648 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -332,10 +332,7 @@ ${config.docsets.map((d) => `• **${d.id}** (${d.name})`).join("\n")}`, if (!existsSync(metadataPath)) { throw new Error( - `Docset '${docset_id}' is not initialized.\n\n` + - `The docset is configured but hasn't been initialized yet.\n\n` + - `To initialize this docset:\n` + - `npx agentic-knowledge-mcp init ${docset_id}\n\n`, + `Docset '${docset_id}' hasn't been initialized yet.`, ); } @@ -361,10 +358,7 @@ ${config.docsets.map((d) => `• **${d.id}** (${d.name})`).join("\n")}`, if (!existsSync(metadataPath)) { throw new Error( - `Docset '${docset_id}' is not initialized.\n\n` + - `The docset is configured but hasn't been initialized yet.\n\n` + - `To initialize this docset:\n` + - `npx agentic-knowledge-mcp init ${docset_id}\n\n`, + `Docset '${docset_id}' hasn't been initialized yet.\n\n`, ); } } else {