diff --git a/packages/cli/src/__tests__/create-command.test.ts b/packages/cli/src/__tests__/create-command.test.ts index 6873fc2..1f37a66 100644 --- a/packages/cli/src/__tests__/create-command.test.ts +++ b/packages/cli/src/__tests__/create-command.test.ts @@ -136,7 +136,7 @@ describe("create command", () => { } }); - it("creates config file when missing and creates symlinks for local folder", async () => { + it("creates config file when missing (symlinks created later via init)", async () => { // Remove the config file to test creation from scratch await fs.rm(join(testDir, ".knowledge"), { recursive: true, force: true }); @@ -176,13 +176,13 @@ describe("create command", () => { expect(config).toContain("name: Test Docs"); expect(config).toContain("type: local_folder"); - // Check symlinks were created + // Symlinks should NOT be created during create (only during init) const symlinkDir = join(testDir, ".knowledge", "docsets", "test-docs"); const symlinkExists = await fs .access(symlinkDir) .then(() => true) .catch(() => false); - expect(symlinkExists).toBe(true); + expect(symlinkExists).toBe(false); } finally { process.cwd = originalCwd; console.log = originalLog; diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 2d6f3a5..0668caa 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -69,28 +69,15 @@ export const createCommand = new Command("create") config.docsets.push(newDocset); await configManager.saveConfig(config, configPath); - // For local folders, create symlinks immediately - if (options.preset === "local-folder") { - console.log(chalk.gray("šŸ”— Creating symlinks for local folder...")); - const { calculateLocalPathWithSymlinks } = await import( - "@codemcp/knowledge-core" - ); - try { - await calculateLocalPathWithSymlinks(newDocset, configPath); - console.log(chalk.gray(" āœ… Symlinks created successfully")); - } catch (error) { - console.log( - chalk.yellow( - ` āš ļø Warning: Could not create symlinks: ${(error as Error).message}`, - ), - ); - } - } - console.log( chalk.green(`āœ… Created docset '${options.id}' successfully`), ); console.log(chalk.gray(` Config saved to: ${configPath}`)); + console.log( + chalk.yellow( + `\nšŸ’” Next step: Initialize the docset with 'agentic-knowledge init ${options.id}'`, + ), + ); } catch (error) { console.error( chalk.red("āŒ Error creating docset:"), diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index d98602e..0fe1667 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -149,6 +149,81 @@ export const initCommand = new Command("init") 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"); + + 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 (error) { + 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), @@ -156,7 +231,7 @@ export const initCommand = new Command("init") } else { console.log( chalk.red( - ` āŒ Source type '${source.type}' not yet supported`, + ` āŒ Source type '${(source as any).type}' not yet supported`, ), ); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 570910a..9d614ca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -25,6 +25,9 @@ export { ensureKnowledgeGitignoreSync, } from "./paths/calculator.js"; +// Export symlink utilities +export { createSymlinks } from "./paths/symlinks.js"; + // Export template processing export { processTemplate, diff --git a/packages/mcp-server/src/__tests__/web-sources.test.ts b/packages/mcp-server/src/__tests__/web-sources.test.ts index 52b1b97..82ee4eb 100644 --- a/packages/mcp-server/src/__tests__/web-sources.test.ts +++ b/packages/mcp-server/src/__tests__/web-sources.test.ts @@ -55,8 +55,8 @@ template: "Search for '{{keywords}}' in {{local_path}}. Also consider: {{general "# Test Documentation\n\nThis simulates downloaded web content.", ); - // Create metadata file (simulating what init command creates) - const metadata = { + // Create metadata file for web source (simulating what init command creates) + const webMetadata = { docset_id: "web-source-docs", docset_name: "Web Source Documentation", initialized_at: new Date().toISOString(), @@ -65,7 +65,35 @@ template: "Search for '{{keywords}}' in {{local_path}}. Also consider: {{general }; await fs.writeFile( join(webSourceDir, ".agentic-metadata.json"), - JSON.stringify(metadata, null, 2), + JSON.stringify(webMetadata, null, 2), + ); + + // Create mock initialized local folder docset (simulating init command for local folders) + const localSourceDir = join(knowledgeDir, "docsets", "local-docs"); + await fs.mkdir(localSourceDir, { recursive: true }); + + // Create actual source directory to symlink to + const actualLocalDocs = join(tempDir, "docs", "local"); + await fs.mkdir(actualLocalDocs, { recursive: true }); + await fs.writeFile( + join(actualLocalDocs, "guide.md"), + "# Local Guide\n\nThis is local documentation.", + ); + + // Create symlink (simulating what init does for local folders) + await fs.symlink(actualLocalDocs, join(localSourceDir, "local"), "dir"); + + // Create metadata file for local folder + const localMetadata = { + docset_id: "local-docs", + docset_name: "Local Documentation", + initialized_at: new Date().toISOString(), + total_files: 1, + sources_count: 1, + }; + await fs.writeFile( + join(localSourceDir, ".agentic-metadata.json"), + JSON.stringify(localMetadata, null, 2), ); // Mock process.cwd to return our temp directory @@ -117,7 +145,7 @@ template: "Search for '{{keywords}}' in {{local_path}}. Also consider: {{general expect(response.path).not.toContain("./docs/"); // Should not use local path pattern }); - it("WHEN MCP server searches local docset THEN should return configured local_path", async () => { + it("WHEN MCP server searches local docset THEN should return symlinked path (consistent with git repos)", async () => { const request = { method: "tools/call", params: { @@ -140,9 +168,9 @@ template: "Search for '{{keywords}}' in {{local_path}}. Also consider: {{general expect(response.search_terms).toContain("configuration setup"); expect(response.generalized_search_terms).toContain("install guide"); - // Should use the configured local_path for traditional docsets - expect(response.path).toContain("docs/local"); - expect(response.path).not.toContain("docsets/local-docs"); // Should not use docsets pattern + // Local folders now use symlinked path (consistent with git repos) + expect(response.path).toContain("docsets/local-docs"); + expect(response.path).not.toContain("docs/local"); // Should not use direct source path }); it("WHEN MCP server lists docsets THEN should include both web and local docsets with correct paths", async () => { diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index e4a4421..090905f 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -12,6 +12,7 @@ import { loadConfig, findConfigPath, calculateLocalPath, + calculateLocalPathWithSymlinks, processTemplate, createTemplateContext, getEffectiveTemplate, @@ -280,13 +281,43 @@ Use the path and search terms with your text search tools (grep, rg, ripgrep, fi ); } - // Calculate local path - const localPath = calculateLocalPath(docset, configPath); - - // Check if docset is initialized by checking for metadata file + // Determine path calculation method and validate initialization const primarySource = docset.sources?.[0]; - if (primarySource?.type === "git_repo") { - // For git repos, check if .agentic-metadata.json exists + let localPath: string; + + if (primarySource?.type === "local_folder") { + // For local folders, use symlinked path + localPath = calculateLocalPath(docset, configPath); + + // Check if initialized by verifying .agentic-metadata.json exists + const configDir = dirname(configPath); + const projectRoot = dirname(configDir); + const symlinkDir = resolve(configDir, "docsets", docset.id); + const metadataPath = resolve(symlinkDir, ".agentic-metadata.json"); + + 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` + + `agentic-knowledge init ${docset_id}\n\n` + + `To check status of all docsets:\n` + + `agentic-knowledge status`, + ); + } + + // Return the symlinked path for consistency + localPath = resolve(configDir, "docsets", docset.id); + const projectRoot2 = dirname(configDir); + localPath = resolve(projectRoot2, localPath).replace( + projectRoot2 + "/", + "", + ); + } else if (primarySource?.type === "git_repo") { + // For git repos, use standard path calculation + localPath = calculateLocalPath(docset, configPath); + + // Check if .agentic-metadata.json exists const configDir = dirname(configPath); const projectRoot = dirname(configDir); const absolutePath = resolve(projectRoot, localPath); @@ -305,6 +336,9 @@ Use the path and search terms with your text search tools (grep, rg, ripgrep, fi `agentic-knowledge status`, ); } + } else { + // Fallback to standard calculation for unknown types + localPath = calculateLocalPath(docset, configPath); } // Create template context with proper function signature @@ -374,13 +408,32 @@ Use the path and search terms with your text search tools (grep, rg, ripgrep, fi // Return list of available docsets with calculated paths const docsets = await Promise.all( - config.docsets.map(async (docset) => ({ - docset_id: docset.id, - docset_name: docset.name, - docset_description: - docset.description || "No description provided", - local_path: await calculateLocalPath(docset, configPath), - })), + config.docsets.map(async (docset) => { + const primarySource = docset.sources?.[0]; + let localPath: string; + + if (primarySource?.type === "local_folder") { + // Use symlinked path for local folders + const configDir = dirname(configPath); + localPath = resolve(configDir, "docsets", docset.id); + const projectRoot = dirname(configDir); + localPath = resolve(projectRoot, localPath).replace( + projectRoot + "/", + "", + ); + } else { + // Use standard calculation for other types + localPath = calculateLocalPath(docset, configPath); + } + + return { + docset_id: docset.id, + docset_name: docset.name, + docset_description: + docset.description || "No description provided", + local_path: localPath, + }; + }), ); const summary = diff --git a/test/utils/e2e-test-setup.ts b/test/utils/e2e-test-setup.ts index daa0ed6..8c6798d 100644 --- a/test/utils/e2e-test-setup.ts +++ b/test/utils/e2e-test-setup.ts @@ -55,6 +55,29 @@ export async function createTestProject( const docFile = join(docsetPath, "README.md"); await fs.writeFile(docFile, docset.content); } + + // Initialize the docset (create symlinks and metadata for local folders) + // This simulates running 'agentic-knowledge init {docset-id}' + const symlinkDir = join(knowledgeDir, "docsets", docset.id); + await fs.mkdir(symlinkDir, { recursive: true }); + + // Create symlink to the actual source directory + const sourceName = docset.localPath.split("/").pop() || docset.id; + const symlinkPath = join(symlinkDir, sourceName); + await fs.symlink(docsetPath, symlinkPath, "dir"); + + // Create metadata file + const metadata = { + docset_id: docset.id, + docset_name: docset.name, + initialized_at: new Date().toISOString(), + total_files: 1, + sources_count: 1, + }; + await fs.writeFile( + join(symlinkDir, ".agentic-metadata.json"), + JSON.stringify(metadata, null, 2), + ); } }