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
44 changes: 8 additions & 36 deletions packages/cli/src/__tests__/cli-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,48 +63,20 @@ docsets:
});

describe("Status Command", () => {
it("should show status with no initialized docsets", async () => {
// Change to test directory and run status command
it("should show status with no initialized docsets", () => {
const originalCwd = process.cwd();
const originalArgv = process.argv;
process.chdir(testDir);

try {
// Mock process.argv for status command
process.argv = ["node", "cli.js", "status"];

// Import and run CLI function directly
const { runCli } = await import("../cli.js");

// Capture console output
let output = "";
const originalLog = console.log;
const originalError = console.error;
const originalWarn = console.warn;
const originalInfo = console.info;

const capture = (...args: any[]) => {
output += args.join(" ") + "\n";
};

console.log = capture;
console.error = capture;
console.warn = capture;
console.info = capture;

await runCli();

// Restore console methods
console.log = originalLog;
console.error = originalError;
console.warn = originalWarn;
console.info = originalInfo;
const output = execSync(`node ${cliPath} status`, {
encoding: "utf8",
timeout: 5000,
});

expect(output).toContain("Agentic Knowledge Status");
expect(output).toContain("Found 2 docset(s) with web sources");
expect(output).toContain("Found 2 docset(s)");
} finally {
process.chdir(originalCwd);
process.argv = originalArgv;
}
});

Expand Down Expand Up @@ -209,8 +181,8 @@ docsets: []
});

expect(output).toContain("Agentic Knowledge Refresh");
expect(output).toContain("Found 2 docset(s) to refresh");
expect(output).toContain("test-docset, unsupported-source-docset");
expect(output).toContain("test-docset");
expect(output).toContain("unsupported-source-docset");
} finally {
process.chdir(originalCwd);
}
Expand Down
171 changes: 171 additions & 0 deletions packages/cli/src/api/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* Typed API for creating new docsets.
*/

import { promises as fs } from "node:fs";
import * as path from "node:path";
import { URL } from "node:url";
import { ConfigManager } from "@codemcp/knowledge-core";
import type { DocsetConfig } from "@codemcp/knowledge-core";
import type {
CreateDocsetParams,
CreateDocsetOptions,
CreateDocsetResult,
} from "./types.js";

/**
* Create a new docset entry in the knowledge configuration.
*
* Validates the parameters for the chosen preset, creates the
* `.knowledge/config.yaml` if it doesn't exist yet, and appends the new
* docset to it.
*
* @throws {Error} when validation fails or the docset ID already exists.
*/
export async function createDocset(
params: CreateDocsetParams,
options?: CreateDocsetOptions,
): Promise<CreateDocsetResult> {
const cwd = options?.cwd ?? process.cwd();
const configManager = new ConfigManager();

const configExists = await configManager.configExists(cwd);
let config: { version: string; docsets: DocsetConfig[] };
let configPath: string;
let configCreated = false;

if (!configExists) {
configPath = path.join(cwd, ".knowledge", "config.yaml");
config = { version: "1.0", docsets: [] };
await fs.mkdir(path.dirname(configPath), { recursive: true });
configCreated = true;
} else {
const loaded = await configManager.loadConfig(cwd);
config = loaded.config;
configPath = loaded.configPath;
}

if (config.docsets.find((d) => d.id === params.id)) {
throw new Error(`Docset with ID '${params.id}' already exists`);
}

let newDocset: DocsetConfig;

if (params.preset === "git-repo") {
newDocset = buildGitRepoDocset(params);
} else if (params.preset === "local-folder") {
newDocset = await buildLocalFolderDocset(params);
} else if (params.preset === "archive") {
newDocset = await buildArchiveDocset(params);
} else {
throw new Error(
`Unknown preset: '${params.preset}'. Use 'git-repo', 'local-folder', or 'archive'`,
);
}

config.docsets.push(newDocset);
await configManager.saveConfig(config, configPath);

return { docset: newDocset, configPath, configCreated };
}

function buildGitRepoDocset(params: CreateDocsetParams): DocsetConfig {
if (!params.url) {
throw new Error("url is required for the git-repo preset");
}
if (!params.url.match(/^https?:\/\/.*\.git$|^git@.*\.git$/)) {
throw new Error("Invalid git URL format. Expected a URL ending with .git");
}
return {
id: params.id,
name: params.name,
description: params.description ?? `Git repository: ${params.url}`,
sources: [
{
url: params.url,
type: "git_repo" as const,
branch: params.branch ?? "main",
...(params.paths !== undefined ? { paths: params.paths } : {}),
},
],
};
}

async function buildLocalFolderDocset(
params: CreateDocsetParams,
): Promise<DocsetConfig> {
if (!params.path) {
throw new Error("path is required for the local-folder preset");
}
const fullPath = path.resolve(params.path);
let stat;
try {
stat = await fs.stat(fullPath);
} catch {
throw new Error(`Path does not exist: ${params.path}`);
}
if (!stat.isDirectory()) {
throw new Error(`Path is not a directory: ${params.path}`);
}
return {
id: params.id,
name: params.name,
description: params.description ?? `Local documentation: ${params.path}`,
sources: [
{
type: "local_folder",
paths: [params.path],
},
],
};
}

async function buildArchiveDocset(
params: CreateDocsetParams,
): Promise<DocsetConfig> {
if (!params.path && !params.url) {
throw new Error("Either path or url is required for the archive preset");
}
if (params.path) {
const fullPath = path.resolve(params.path);
let stat;
try {
stat = await fs.stat(fullPath);
} catch {
throw new Error(`Path does not exist or is invalid: ${params.path}`);
}
if (!stat.isFile()) {
throw new Error(`Path is not a file: ${params.path}`);
}
const lower = params.path.toLowerCase();
if (
!lower.endsWith(".zip") &&
!lower.endsWith(".tar.gz") &&
!lower.endsWith(".tgz")
) {
throw new Error(
`Unsupported archive format. Expected .zip, .tar.gz or .tgz: ${params.path}`,
);
}
}
if (params.url) {
try {
new URL(params.url);
} catch {
throw new Error(`Invalid URL format: ${params.url}`);
}
}
return {
id: params.id,
name: params.name,
description: params.description ?? `Archive: ${params.path ?? params.url}`,
sources: [
{
type: "archive",
...(params.path ? { path: params.path } : {}),
...(params.url ? { url: params.url } : {}),
...(params.paths ? { paths: params.paths } : {}),
},
],
};
}
58 changes: 58 additions & 0 deletions packages/cli/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Typed programmatic API for agentic-knowledge CLI features.
*
* Use these functions to interact with docsets from code without going through
* the CLI binary. All functions throw typed errors instead of calling
* `process.exit()`, return structured results instead of printing to stdout,
* and accept an optional `cwd` option so callers can control which
* `.knowledge/config.yaml` is used.
*
* @example
* ```ts
* import { createDocset, initDocset, getStatus } from "@codemcp/knowledge-cli";
*
* // Add a new git-repo docset to the config
* const { docset, configPath } = await createDocset({
* preset: "git-repo",
* id: "my-docs",
* name: "My Docs",
* url: "https://github.com/example/docs.git",
* });
*
* // Download the sources
* const result = await initDocset({ docsetId: "my-docs" });
* console.log(`Initialized ${result.totalFiles} files at ${result.localPath}`);
*
* // Query status
* const status = await getStatus();
* console.log(status.docsets);
* ```
*/

export { createDocset } from "./create.js";
export { initDocset } from "./init.js";
export { refreshDocsets } from "./refresh.js";
export { getStatus } from "./status.js";
export type {
// create
DocsetPreset,
CreateDocsetParams,
CreateDocsetOptions,
CreateDocsetResult,
// init
InitDocsetParams,
InitDocsetApiResult,
// refresh
RefreshParams,
RefreshResult,
DocsetRefreshResult,
// status
StatusParams,
StatusResult,
DocsetStatusInfo,
DocsetSourceStatus,
// re-exported from dependencies
DocsetConfig,
SourceResult,
InitDocsetResult,
} from "./types.js";
73 changes: 73 additions & 0 deletions packages/cli/src/api/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Typed API for initialising docset sources.
*/

import {
ConfigManager,
ensureKnowledgeGitignoreSync,
discoverDirectoryPatterns,
} from "@codemcp/knowledge-core";
import {
initDocset as coreInitDocset,
type InitDocsetOptions,
} from "@codemcp/knowledge-content-loader";
import type { InitDocsetParams, InitDocsetApiResult } from "./types.js";

/**
* Initialise all sources for a docset that is already defined in the config.
*
* Downloads / symlinks each source and writes the metadata files that the MCP
* server uses to serve the docset.
*
* @throws {Error} when the config cannot be found or the docset ID is unknown.
*/
export async function initDocset(
params: InitDocsetParams,
): Promise<InitDocsetApiResult> {
const {
docsetId,
force = false,
discoverPaths = false,
cwd = process.cwd(),
onSourceProgress,
} = params;

const configManager = new ConfigManager();
const { config, configPath } = await configManager.loadConfig(cwd);

ensureKnowledgeGitignoreSync(configPath);

const docset = config.docsets.find((d) => d.id === docsetId);
if (!docset) {
const available = config.docsets.map((d) => d.id).join(", ");
throw new Error(
`Docset '${docsetId}' not found in configuration. Available: ${available}`,
);
}

const coreOpts: InitDocsetOptions = { force };
if (onSourceProgress !== undefined) {
coreOpts.onSourceProgress = onSourceProgress;
}

const result = await coreInitDocset(docsetId, docset, configPath, coreOpts);

if (result.alreadyInitialized) {
return { ...result };
}

if (discoverPaths) {
const allFiles = result.sourceResults.flatMap((r) => r.files);
if (allFiles.length > 0) {
const discovered = discoverDirectoryPatterns(allFiles);
try {
await configManager.updateDocsetPaths(docsetId, discovered);
} catch {
// Non-fatal: surface the discovered paths even if config update failed.
}
return { ...result, discoveredPaths: discovered };
}
}

return { ...result };
}
Loading
Loading