-
Notifications
You must be signed in to change notification settings - Fork 660
feat(genkit-tools/cli): Add docs discovery tools to the CLI #4453
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: sb/mcpDocsImprovements
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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}`); | ||||||||||||||
| } | ||||||||||||||
| }); | ||||||||||||||
|
|
||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For improved type safety, it's a best practice to catch errors as
Suggested change
|
||||||||||||||
| }); | ||||||||||||||
|
|
||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For improved type safety, it's a best practice to catch errors as
Suggested change
|
||||||||||||||
| }); | ||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For improved type safety, it's a best practice to catch errors as
unknownand then check their type before accessing properties likemessage. This avoids potential runtime errors if a non-Error object is thrown.