Skip to content
Draft
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: 4 additions & 0 deletions genkit-tools/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import { Command, program } from 'commander';
import { config } from './commands/config';
import { devTestModel } from './commands/dev-test-model';
import { docsList, docsRead, docsSearch } from './commands/docs';
import { evalExtractData } from './commands/eval-extract-data';
import { evalFlow } from './commands/eval-flow';
import { evalRun } from './commands/eval-run';
Expand Down Expand Up @@ -62,6 +63,9 @@ const commands: Command[] = [
start,
devTestModel,
mcp,
docsList,
docsRead,
docsSearch,
];

/** Main entry point for CLI. */
Expand Down
107 changes: 107 additions & 0 deletions genkit-tools/cli/src/commands/docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { logger } from '@genkit-ai/tools-common/utils';
import * as clc from 'colorette';
import { Command } from 'commander';
import { loadDocs, searchDocs } from '../utils/docs';

export const docsList = new Command('docs:list')
.description('list available Genkit documentation files')
.argument('[language]', 'language to list docs for (js, go, python)', 'js')
.action(async (language) => {
try {
const documents = await loadDocs();
const lang = language || 'js';
const fileList = Object.keys(documents)
.filter((file) => file.startsWith(lang))
.sort();

if (fileList.length === 0) {
logger.info(`No documentation found for language: ${lang}`);
return;
}

logger.info(`Genkit Documentation Index (${lang}):\n`);
fileList.forEach((file) => {
const doc = documents[file];
logger.info(`${clc.bold(doc.title)}`);
logger.info(` Path: ${file}`);
if (doc.description) {
logger.info(` ${clc.italic(doc.description)}`);
}
logger.info('');
});
logger.info(`Use 'genkit docs:read <path>' to read a document.`);
} catch (e: any) {
logger.error(`Failed to load documentation: ${e.message}`);
}
Comment on lines +49 to +51
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

For improved type safety, it's a best practice to catch errors as unknown and then check their type before accessing properties like message. This avoids potential runtime errors if a non-Error object is thrown.

Suggested change
} catch (e: any) {
logger.error(`Failed to load documentation: ${e.message}`);
}
} catch (e: unknown) {
logger.error(`Failed to load documentation: ${e instanceof Error ? e.message : String(e)}`);
}

});

export const docsSearch = new Command('docs:search')
.description('search Genkit documentation')
.argument(
'<query>',
'keywords to search for. For multiple keywords, enclose in quotes. E.g. "stream flows"'
)
.argument('[language]', 'language to search docs for (js, go, python)', 'js')
.action(async (query, language) => {
try {
const documents = await loadDocs();
const lang = language || 'js';
const results = searchDocs(documents, query, lang).slice(0, 10);

if (results.length === 0) {
logger.info(`No results found for "${query}" in ${lang} docs.`);
return;
}

logger.info(
`Found ${results.length} matching documents for "${query}":\n`
);
results.forEach((r) => {
logger.info(`${clc.bold(r.doc.title)}`);
logger.info(` Path: ${r.file}`);
if (r.doc.description) {
logger.info(` ${clc.italic(r.doc.description)}`);
}
logger.info('');
});
} catch (e: any) {
logger.error(`Failed to load documentation: ${e.message}`);
}
Comment on lines +83 to +85
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

For improved type safety, it's a best practice to catch errors as unknown and then check their type before accessing properties like message. This avoids potential runtime errors if a non-Error object is thrown.

Suggested change
} catch (e: any) {
logger.error(`Failed to load documentation: ${e.message}`);
}
} catch (e: unknown) {
logger.error(`Failed to load documentation: ${e instanceof Error ? e.message : String(e)}`);
}

});

export const docsRead = new Command('docs:read')
.description('read a Genkit documentation file')
.argument('<filePath>', 'path of the document to read')
.action(async (filePath) => {
try {
const documents = await loadDocs();
const doc = documents[filePath];
if (!doc) {
logger.error(`Document not found: ${filePath}`);
return;
}

logger.info(clc.bold(doc.title));
logger.info('='.repeat(doc.title.length));
logger.info('');
logger.info(doc.text);
} catch (e: any) {
logger.error(`Failed to load documentation: ${e.message}`);
}
Comment on lines +104 to +106
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

For improved type safety, it's a best practice to catch errors as unknown and then check their type before accessing properties like message. This avoids potential runtime errors if a non-Error object is thrown.

Suggested change
} catch (e: any) {
logger.error(`Failed to load documentation: ${e.message}`);
}
} catch (e: unknown) {
logger.error(`Failed to load documentation: ${e instanceof Error ? e.message : String(e)}`);
}

});
77 changes: 3 additions & 74 deletions genkit-tools/cli/src/mcp/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,59 +17,12 @@
import { record } from '@genkit-ai/tools-common/utils';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { ContentBlock } from '@modelcontextprotocol/sdk/types';
import { existsSync, mkdirSync, readFileSync, renameSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { Readable } from 'node:stream';
import os from 'os';
import path from 'path';
import z from 'zod';
import { version } from '../utils/version';
import { loadDocs, searchDocs } from '../utils/docs';
import { McpRunToolEvent } from './analytics.js';

const DOCS_URL =
process.env.GENKIT_DOCS_BUNDLE_URL ??
'http://genkit.dev/docs-bundle-experimental.json';

const DOCS_BUNDLE_FILE_PATH = path.resolve(
os.homedir(),
'.genkit',
'docs',
version,
'bundle.json'
);

async function maybeDownloadDocsBundle() {
if (existsSync(DOCS_BUNDLE_FILE_PATH)) {
return;
}
const response = await fetch(DOCS_URL);
if (response.status !== 200) {
throw new Error(
'Failed to download genkit docs bundle. Try again later or/and report the issue.\n\n' +
DOCS_URL
);
}
const stream = Readable.fromWeb(response.body as any);

mkdirSync(path.dirname(DOCS_BUNDLE_FILE_PATH), { recursive: true });

await writeFile(DOCS_BUNDLE_FILE_PATH + '.pending', stream);
renameSync(DOCS_BUNDLE_FILE_PATH + '.pending', DOCS_BUNDLE_FILE_PATH);
}

interface Doc {
title: string;
description?: string;
text: string;
lang: string;
headers: string;
}

export async function defineDocsTool(server: McpServer) {
await maybeDownloadDocsBundle();
const documents = JSON.parse(
readFileSync(DOCS_BUNDLE_FILE_PATH, { encoding: 'utf8' })
) as Record<string, Doc>;
const documents = await loadDocs();

server.registerTool(
'list_genkit_docs',
Expand Down Expand Up @@ -138,32 +91,8 @@ export async function defineDocsTool(server: McpServer) {
async ({ query, language }) => {
await record(new McpRunToolEvent('search_genkit_docs'));
const lang = language || 'js';
const terms = query
.toLowerCase()
.split(/\s+/)
.filter((t) => t.length > 2); // Filter out short words to reduce noise

const results = Object.keys(documents)
.filter((file) => file.startsWith(lang))
.map((file) => {
const doc = documents[file];
let score = 0;
const title = doc.title.toLowerCase();
const desc = (doc.description || '').toLowerCase();
const headers = (doc.headers || '').toLowerCase();

terms.forEach((term) => {
if (title.includes(term)) score += 10;
if (desc.includes(term)) score += 5;
if (headers.includes(term)) score += 3;
if (file.includes(term)) score += 5;
});

return { file, doc, score };
})
.filter((r) => r.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 10); // Top 10
const results = searchDocs(documents, query, lang).slice(0, 10); // Top 10

if (results.length === 0) {
return {
Expand Down
112 changes: 112 additions & 0 deletions genkit-tools/cli/src/utils/docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
statSync,
} from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { Readable } from 'node:stream';
import os from 'os';
import path from 'path';
import { version } from './version';

export const DOCS_URL =
process.env.GENKIT_DOCS_BUNDLE_URL ??
'http://genkit.dev/docs-bundle-experimental.json';

export const DOCS_BUNDLE_FILE_PATH = path.resolve(
os.homedir(),
'.genkit',
'docs',
version,
'bundle.json'
);

export async function maybeDownloadDocsBundle() {
if (existsSync(DOCS_BUNDLE_FILE_PATH)) {
const stats = statSync(DOCS_BUNDLE_FILE_PATH);
const DOCS_TTL = 1000 * 60 * 60 * 24 * 7; // 1 week
if (Date.now() - stats.mtimeMs < DOCS_TTL) {
return;
}
}
const response = await fetch(DOCS_URL);
if (response.status !== 200) {
throw new Error(
'Failed to download genkit docs bundle. Try again later or/and report the issue.\n\n' +
DOCS_URL
);
}
const stream = Readable.fromWeb(response.body as any);

mkdirSync(path.dirname(DOCS_BUNDLE_FILE_PATH), { recursive: true });

await writeFile(DOCS_BUNDLE_FILE_PATH + '.pending', stream);
renameSync(DOCS_BUNDLE_FILE_PATH + '.pending', DOCS_BUNDLE_FILE_PATH);
}

export interface Doc {
title: string;
description?: string;
text: string;
lang: string;
headers: string;
}

export async function loadDocs(): Promise<Record<string, Doc>> {
await maybeDownloadDocsBundle();
return JSON.parse(
readFileSync(DOCS_BUNDLE_FILE_PATH, { encoding: 'utf8' })
) as Record<string, Doc>;
}

export function searchDocs(
documents: Record<string, Doc>,
query: string,
lang: string
) {
const terms = query
.toLowerCase()
.split(/\s+/)
.filter((t) => t.length > 2); // Filter out short words to reduce noise

const results = Object.keys(documents)
.filter((file) => file.startsWith(lang))
.map((file) => {
const doc = documents[file];
let score = 0;
const title = doc.title.toLowerCase();
const desc = (doc.description || '').toLowerCase();
const headers = (doc.headers || '').toLowerCase();

terms.forEach((term) => {
if (title.includes(term)) score += 10;
if (desc.includes(term)) score += 5;
if (headers.includes(term)) score += 3;
if (file.includes(term)) score += 5;
});
Comment on lines +99 to +104
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The scoring logic uses magic numbers (10, 5, 3, 5), which can make the code harder to understand and maintain. It's better to extract these values into named constants to improve readability and make it easier to adjust the scoring in the future.

      const TITLE_SCORE = 10;
      const DESC_SCORE = 5;
      const HEADERS_SCORE = 3;
      const FILE_PATH_SCORE = 5;

      terms.forEach((term) => {
        if (title.includes(term)) score += TITLE_SCORE;
        if (desc.includes(term)) score += DESC_SCORE;
        if (headers.includes(term)) score += HEADERS_SCORE;
        if (file.includes(term)) score += FILE_PATH_SCORE;
      });


return { file, doc, score };
})
.filter((r) => r.score > 0)
.sort((a, b) => b.score - a.score);

return results;
}
Loading