Skip to content
Open
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
39 changes: 39 additions & 0 deletions USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/__tests__/cli-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/api/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard is exactly right for create. But init writes to the config too — ensureKnowledgeGitignoreSync(configPath), coreInitDocset(..., configPath, ...) (drops metadata into the .knowledge dir), and updateDocsetPaths(...) (rewrites config.yaml) — all reached via configManager.loadConfig(cwd) in init.ts with no includeHome option. So a project with no local config but a matching docset in ~/.knowledge/config.yaml will have init write into the home config — the same class of bug this fixes here. Suggest extending the guard to init (and reconsidering refresh, which also calls ensureKnowledgeGitignoreSync), or documenting why init-to-home is intended.

const configExists = await configManager.configExists(cwd, discovery);
let config: { version: string; docsets: DocsetConfig[] };
let configPath: string;
let configCreated = false;
Expand All @@ -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;
}
Expand Down
11 changes: 9 additions & 2 deletions packages/cli/src/api/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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.
}
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/api/refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,12 @@ export async function refreshDocsets(
): Promise<RefreshResult> {
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.",
);
}

Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/api/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,12 @@ interface RawSourceMetadata {
*/
export async function getStatus(params?: StatusParams): Promise<StatusResult> {
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.",
);
}

Expand Down
119 changes: 119 additions & 0 deletions packages/core/src/__tests__/config-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>;

async function writeConfig(
directory: string,
docsetId: string,
): Promise<string> {
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/");
});
});
Loading
Loading