Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/cli/src/__tests__/create-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down Expand Up @@ -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;
Expand Down
23 changes: 5 additions & 18 deletions packages/cli/src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:"),
Expand Down
77 changes: 76 additions & 1 deletion packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,89 @@
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) {

Check warning on line 183 in packages/cli/src/commands/init.ts

View workflow job for this annotation

GitHub Actions / test

'error' is defined but never used. Allowed unused caught errors must match /^_/u
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<void> {
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 {
console.log(
chalk.red(
` ❌ Source type '${source.type}' not yet supported`,
` ❌ Source type '${(source as any).type}' not yet supported`,
),
);
}
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export {
ensureKnowledgeGitignoreSync,
} from "./paths/calculator.js";

// Export symlink utilities
export { createSymlinks } from "./paths/symlinks.js";

// Export template processing
export {
processTemplate,
Expand Down
42 changes: 35 additions & 7 deletions packages/mcp-server/src/__tests__/web-sources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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
Expand Down Expand Up @@ -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: {
Expand All @@ -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 () => {
Expand Down
79 changes: 66 additions & 13 deletions packages/mcp-server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
loadConfig,
findConfigPath,
calculateLocalPath,
calculateLocalPathWithSymlinks,

Check warning on line 15 in packages/mcp-server/src/server.ts

View workflow job for this annotation

GitHub Actions / test

'calculateLocalPathWithSymlinks' is defined but never used. Allowed unused vars must match /^_/u
processTemplate,
createTemplateContext,
getEffectiveTemplate,
Expand Down Expand Up @@ -280,13 +281,43 @@
);
}

// 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);

Check warning on line 294 in packages/mcp-server/src/server.ts

View workflow job for this annotation

GitHub Actions / test

'projectRoot' is assigned a value but never used. Allowed unused vars must match /^_/u
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);
Expand All @@ -305,6 +336,9 @@
`agentic-knowledge status`,
);
}
} else {
// Fallback to standard calculation for unknown types
localPath = calculateLocalPath(docset, configPath);
}

// Create template context with proper function signature
Expand Down Expand Up @@ -374,13 +408,32 @@

// 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 =
Expand Down
23 changes: 23 additions & 0 deletions test/utils/e2e-test-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
}
}

Expand Down
Loading