diff --git a/README.md b/README.md index aa7b007c..c6c596a4 100644 --- a/README.md +++ b/README.md @@ -107,9 +107,28 @@ https://learn.microsoft.com/api/mcp?maxTokenBudget=2000 | `microsoft_docs_fetch` | Fetch and convert a Microsoft documentation page into markdown format | `url` (string): URL of the documentation page to read | | `microsoft_code_sample_search` | Search for official Microsoft/Azure code snippets and examples | `query` (string): Search query for Microsoft/Azure code snippets
`language` (string, optional): Programming language filter.| -## 💻 Companion CLI +## 💻 Microsoft Learn CLI -This repository also includes an in-repo companion CLI. See [`cli/README.md`](cli/README.md) for installation, usage, and full command reference. +The [`@microsoft/learn-cli`](https://www.npmjs.com/package/@microsoft/learn-cli) package gives you terminal access to the same tools — search docs, fetch pages, and find code samples — without an MCP client. + +```bash +# Run instantly (no install) +npx @microsoft/learn-cli search "azure functions timeout" + +# Or install globally +npm install -g @microsoft/learn-cli +mslearn search "azure functions timeout" +mslearn code-search "BlobServiceClient" --language python +mslearn fetch "https://learn.microsoft.com/azure/azure-functions/functions-versions" +``` + +Pass `--json` to get raw JSON output for piping to other tools: + +```bash +mslearn search "azure openai" --json | jq '.results[].title' +``` + +See [`cli/README.md`](cli/README.md) for the full command reference. ## 🤖 Agent Skills diff --git a/cli/README.md b/cli/README.md index 45a9e5bc..039dec10 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,6 +1,6 @@ -# Microsoft Learn Companion CLI +# Microsoft Learn CLI -`mslearn` is a thin companion CLI for the public Microsoft Learn MCP server. +`mslearn` is a terminal CLI for the public Microsoft Learn MCP server. It gives you terminal-friendly commands for docs search, docs fetch, code sample search, and environment diagnostics. @@ -18,45 +18,19 @@ This project requires Node.js 22 or later. node --version ``` -## Quick Start +## Installation -From the `cli` directory: +### Option A: Run instantly with `npx` (no install) ```bash -npm install -npm run build -npm test -node dist/index.js --help -``` - -After building, you can run the CLI in three ways. - -### Option A: Run with Node directly - -```bash -node dist/index.js search "azure functions timeout" -``` - -### Option B: Run with `npx` - -```bash -npx . search "azure functions timeout" -``` - -### Option C: Use `npm link` for local CLI-style usage - -```bash -npm link -mslearn search "azure functions timeout" +npx @microsoft/learn-cli search "azure functions timeout" ``` -After `npm link`, you can use the CLI like a normal command: +### Option B: Install globally ```bash +npm install -g @microsoft/learn-cli mslearn search "azure functions timeout" -mslearn fetch "https://learn.microsoft.com/azure/azure-functions/functions-versions" --section "Function app timeout duration" -mslearn code-search "cosmos db change feed processor" --language csharp -mslearn doctor ``` ## Commands @@ -81,6 +55,15 @@ Available commands: - `code-search --language ` searches official code samples. - `doctor [--format text|json]` checks runtime and connectivity. +The `search` and `code-search` commands output human-readable formatted text by +default. Pass `--json` to get the raw JSON response, which is useful for piping +to other tools: + +```bash +mslearn search "azure functions" --json | jq '.results[].title' +mslearn code-search "BlobServiceClient" --language python --json +``` + ## Endpoint configuration To override the default endpoint, set `MSLEARN_ENDPOINT` or pass `--endpoint ` for a single command. @@ -91,3 +74,15 @@ Example in PowerShell: $env:MSLEARN_ENDPOINT = "https://learn.microsoft.com/api/mcp" mslearn doctor ``` + +## Development + +To build and test from source: + +```bash +cd cli +npm install +npm run build +npm test +node dist/index.js --help +``` diff --git a/cli/package.json b/cli/package.json index 95f50bc9..219bf377 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "@microsoft/learn-cli", "version": "0.1.0", - "description": "Thin companion CLI for the Microsoft Learn MCP server.", + "description": "CLI for the Microsoft Learn MCP server.", "type": "module", "bin": { "mslearn": "./dist/index.js" diff --git a/cli/src/commands/code-search.ts b/cli/src/commands/code-search.ts index c3c02f99..0302da52 100644 --- a/cli/src/commands/code-search.ts +++ b/cli/src/commands/code-search.ts @@ -1,11 +1,13 @@ import { Command } from 'commander'; import type { CliContext } from '../context.js'; +import { formatCodeSearchResults } from '../formatters/search-results.js'; import { resolveEndpoint } from '../utils/options.js'; import { ensureTrailingNewline } from '../utils/text.js'; interface CodeSearchCommandOptions { language?: string; + json?: boolean; } export function registerCodeSearchCommand(program: Command, context: CliContext): void { @@ -14,6 +16,7 @@ export function registerCodeSearchCommand(program: Command, context: CliContext) .description('Search Microsoft Learn code samples through the Learn MCP server.') .argument('', 'Search query.') .option('--language ', 'Preferred language filter to pass to Learn.') + .option('--json', 'Output raw JSON instead of formatted text.') .action(async (query: string, options: CodeSearchCommandOptions) => { const endpoint = resolveEndpoint(program.opts<{ endpoint?: string }>().endpoint, context.env); const client = context.createClient({ @@ -24,7 +27,8 @@ export function registerCodeSearchCommand(program: Command, context: CliContext) try { const payload = await client.searchCodeSamples(query, options.language); - context.writeOut(ensureTrailingNewline(payload)); + const output = options.json ? payload : formatCodeSearchResults(payload); + context.writeOut(ensureTrailingNewline(output)); } finally { await client.close(); } diff --git a/cli/src/commands/search.ts b/cli/src/commands/search.ts index 488c1ef9..c8424ee0 100644 --- a/cli/src/commands/search.ts +++ b/cli/src/commands/search.ts @@ -1,15 +1,21 @@ import { Command } from 'commander'; import type { CliContext } from '../context.js'; +import { formatSearchResults } from '../formatters/search-results.js'; import { resolveEndpoint } from '../utils/options.js'; import { ensureTrailingNewline } from '../utils/text.js'; +interface SearchCommandOptions { + json?: boolean; +} + export function registerSearchCommand(program: Command, context: CliContext): void { program .command('search') .description('Search official Microsoft documentation through the Learn MCP server.') .argument('', 'Search query.') - .action(async (query: string) => { + .option('--json', 'Output raw JSON instead of formatted text.') + .action(async (query: string, options: SearchCommandOptions) => { const endpoint = resolveEndpoint(program.opts<{ endpoint?: string }>().endpoint, context.env); const client = context.createClient({ endpoint, @@ -19,7 +25,8 @@ export function registerSearchCommand(program: Command, context: CliContext): vo try { const payload = await client.searchDocs(query); - context.writeOut(ensureTrailingNewline(payload)); + const output = options.json ? payload : formatSearchResults(payload); + context.writeOut(ensureTrailingNewline(output)); } finally { await client.close(); } diff --git a/cli/src/formatters/search-results.ts b/cli/src/formatters/search-results.ts new file mode 100644 index 00000000..3548b12b --- /dev/null +++ b/cli/src/formatters/search-results.ts @@ -0,0 +1,118 @@ +const INDENT = ' '; + +interface FormatOptions { + /** When true, apply code-search-specific cleanup (description metadata stripping, language suffix). */ + codeSearch?: boolean; +} + +export function formatSearchResults(payload: string): string { + return formatResultsPayload(payload, { codeSearch: false }); +} + +export function formatCodeSearchResults(payload: string): string { + return formatResultsPayload(payload, { codeSearch: true }); +} + +function formatResultsPayload(payload: string, options: FormatOptions): string { + let data: unknown; + try { + data = JSON.parse(payload); + } catch { + return payload; + } + + const results = extractResultsArray(data); + if (!results || results.length === 0) { + return JSON.stringify(data, null, 2); + } + + const formatter = options.codeSearch ? formatCodeSearchResult : formatDocsSearchResult; + return results + .map((item, index) => formatter(item as Record, index + 1)) + .join('\n\n'); +} + +function extractResultsArray(data: unknown): unknown[] | undefined { + if (Array.isArray(data)) { + return data; + } + + if (data && typeof data === 'object' && 'results' in data) { + const results = (data as Record).results; + if (Array.isArray(results)) { + return results; + } + } + + return undefined; +} + +function formatDocsSearchResult(result: Record, index: number): string { + const title = stringField(result, 'title') ?? `Result ${index}`; + const url = stringField(result, 'contentUrl') ?? stringField(result, 'url'); + const body = stringField(result, 'content'); + + const lines: string[] = []; + lines.push(`[${index}] ${title}`); + + if (url) { + lines.push(`${INDENT}${url}`); + } + + if (body) { + lines.push(''); + lines.push(body); + } + + return lines.join('\n'); +} + +function formatCodeSearchResult(result: Record, index: number): string { + const rawTitle = stringField(result, 'description') ?? stringField(result, 'title'); + const title = rawTitle ? cleanCodeSearchDescription(rawTitle) : `Result ${index}`; + const url = stringField(result, 'link') ?? stringField(result, 'contentUrl') ?? stringField(result, 'url'); + const language = stringField(result, 'language'); + const body = stringField(result, 'codeSnippet') ?? stringField(result, 'content'); + + const lines: string[] = []; + + const langSuffix = language ? ` (${language})` : ''; + lines.push(`[${index}] ${title}${langSuffix}`); + + if (url) { + lines.push(`${INDENT}${url}`); + } + + if (body) { + lines.push(''); + lines.push(body); + } + + return lines.join('\n'); +} + +/** + * The Learn MCP code-search description field sometimes embeds structured + * metadata lines (e.g. "description: …\npackage: …\nlanguage: …\n"). + * Strip those so the title line stays clean. + */ +function cleanCodeSearchDescription(raw: string): string { + let text = raw; + + // Strip leading "description: " prefix (case-insensitive). + text = text.replace(/^\s*description:\s*/i, ''); + + // Drop trailing metadata lines like "package: ..." and "language: ...". + text = text + .split(/\r?\n/) + .filter((line) => !/^\s*(package|language):\s*/i.test(line)) + .join(' ') + .trim(); + + return text || raw; +} + +function stringField(obj: Record, key: string): string | undefined { + const value = obj[key]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 09cc2744..14f610f2 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -20,7 +20,7 @@ export function createProgram(context: CliContext): Command { program .name('mslearn') - .description('Thin companion CLI for the Microsoft Learn MCP server.') + .description('CLI for the Microsoft Learn MCP server.') .version(context.version) .addOption(new Option('--endpoint ', 'Override the Learn MCP endpoint for this command.').hideHelp()) .showHelpAfterError() diff --git a/cli/test/unit/cli.test.ts b/cli/test/unit/cli.test.ts index 27154530..49b79382 100644 --- a/cli/test/unit/cli.test.ts +++ b/cli/test/unit/cli.test.ts @@ -56,7 +56,7 @@ describe('runCli', () => { expect(stdout.join('')).not.toContain('--endpoint '); }); - it('returns the raw search payload', async () => { + it('formats search results with one result per block', async () => { const client = createMockClient({ searchDocs: vi .fn() @@ -69,15 +69,38 @@ describe('runCli', () => { const exitCode = await runCli(['node', 'mslearn', 'search', 'azure functions timeout'], context); expect(exitCode).toBe(0); - expect(JSON.parse(stdout.join(''))).toEqual({ - results: [ - { - title: 'Azure Functions runtime versions overview', - contentUrl: 'https://learn.microsoft.com/example', - content: 'The functionTimeout property in host.json sets the timeout duration.', - }, - ], + const output = stdout.join(''); + expect(output).toContain('[1] Azure Functions runtime versions overview'); + expect(output).toContain('https://learn.microsoft.com/example'); + expect(output).toContain('The functionTimeout property in host.json sets the timeout duration.'); + }); + + it('outputs raw JSON from search when --json is passed', async () => { + const rawPayload = + '{"results":[{"title":"Test","contentUrl":"https://learn.microsoft.com/example","content":"Body."}]}'; + const client = createMockClient({ + searchDocs: vi.fn().mockResolvedValue(rawPayload), }); + const { context, stdout } = createTestContext(client); + + const exitCode = await runCli(['node', 'mslearn', 'search', 'test query', '--json'], context); + + expect(exitCode).toBe(0); + expect(JSON.parse(stdout.join(''))).toEqual(JSON.parse(rawPayload)); + }); + + it('outputs raw JSON from code-search when --json is passed', async () => { + const rawPayload = + '{"results":[{"description":"desc","codeSnippet":"x = 1","link":"https://example.com","language":"python"}]}'; + const client = createMockClient({ + searchCodeSamples: vi.fn().mockResolvedValue(rawPayload), + }); + const { context, stdout } = createTestContext(client); + + const exitCode = await runCli(['node', 'mslearn', 'code-search', 'test query', '--json'], context); + + expect(exitCode).toBe(0); + expect(JSON.parse(stdout.join(''))).toEqual(JSON.parse(rawPayload)); }); it('filters fetched markdown by section', async () => { diff --git a/cli/test/unit/search-results.test.ts b/cli/test/unit/search-results.test.ts new file mode 100644 index 00000000..1f60f55a --- /dev/null +++ b/cli/test/unit/search-results.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest'; + +import { formatSearchResults, formatCodeSearchResults } from '../../src/formatters/search-results.js'; + +describe('formatSearchResults', () => { + it('formats a single result with title, url, and content', () => { + const payload = JSON.stringify({ + results: [ + { + title: 'Azure Functions overview', + contentUrl: 'https://learn.microsoft.com/example', + content: 'Overview of Azure Functions.', + }, + ], + }); + + const output = formatSearchResults(payload); + + expect(output).toBe( + [ + '[1] Azure Functions overview', + ' https://learn.microsoft.com/example', + '', + 'Overview of Azure Functions.', + ].join('\n'), + ); + }); + + it('formats multiple results separated by blank lines', () => { + const payload = JSON.stringify({ + results: [ + { + title: 'First', + contentUrl: 'https://example.com/1', + content: 'Content one.', + }, + { + title: 'Second', + contentUrl: 'https://example.com/2', + content: 'Content two.', + }, + ], + }); + + const output = formatSearchResults(payload); + + expect(output).toContain('[1] First'); + expect(output).toContain('[2] Second'); + expect(output).toContain('\n\n[2]'); + }); + + it('preserves multi-line content without adding indentation', () => { + const payload = JSON.stringify({ + results: [ + { + title: 'Multi-line', + contentUrl: 'https://example.com/ml', + content: 'Line one.\nLine two.\nLine three.', + }, + ], + }); + + const output = formatSearchResults(payload); + + expect(output).toContain('\n\nLine one.\nLine two.\nLine three.'); + }); + + it('pretty-prints JSON when results array is empty', () => { + const payload = '{"results":[]}'; + + const output = formatSearchResults(payload); + + expect(output).toBe(JSON.stringify({ results: [] }, null, 2)); + }); + + it('returns raw text when payload is not valid JSON', () => { + const payload = 'This is plain text from the server.'; + + const output = formatSearchResults(payload); + + expect(output).toBe(payload); + }); + + it('falls back to Result N when title is missing', () => { + const payload = JSON.stringify({ + results: [{ contentUrl: 'https://example.com/no-title', content: 'Some content.' }], + }); + + const output = formatSearchResults(payload); + + expect(output).toContain('[1] Result 1'); + }); + + it('handles a top-level array of results', () => { + const payload = JSON.stringify([ + { title: 'Direct array item', url: 'https://example.com/arr', content: 'Inline.' }, + ]); + + const output = formatSearchResults(payload); + + expect(output).toContain('[1] Direct array item'); + expect(output).toContain('https://example.com/arr'); + }); + + it('uses url field when contentUrl is absent', () => { + const payload = JSON.stringify({ + results: [{ title: 'Fallback URL', url: 'https://example.com/fallback', content: 'Body.' }], + }); + + const output = formatSearchResults(payload); + + expect(output).toContain('https://example.com/fallback'); + }); + + it('does not append language suffix for docs search results', () => { + const payload = JSON.stringify({ + results: [{ title: 'Article (programming-language-csharp)', contentUrl: 'https://example.com', content: 'Body.' }], + }); + + const output = formatSearchResults(payload); + + expect(output).toContain('[1] Article (programming-language-csharp)'); + expect(output).not.toContain(') ('); + }); +}); + +describe('formatCodeSearchResults', () => { + it('cleans embedded metadata from the description field', () => { + const payload = JSON.stringify({ + results: [ + { + description: + 'description: Creates a BlobServiceClient using SAS token.\npackage: azure-storage-blob\nlanguage: python\n', + link: 'https://learn.microsoft.com/azure/example', + language: 'python', + codeSnippet: 'from azure.storage.blob import BlobServiceClient\nclient = BlobServiceClient(url)', + }, + ], + }); + + const output = formatCodeSearchResults(payload); + + expect(output).toContain('[1] Creates a BlobServiceClient using SAS token. (python)'); + expect(output).not.toContain('description:'); + expect(output).not.toContain('package:'); + expect(output).toContain(' https://learn.microsoft.com/azure/example'); + expect(output).toContain('from azure.storage.blob import BlobServiceClient'); + }); + + it('strips metadata lines even without whitespace after the colon', () => { + const payload = JSON.stringify({ + results: [ + { + description: 'description:No space.\npackage:azure-storage-blob\nlanguage:python\n', + language: 'python', + codeSnippet: 'x = 1', + }, + ], + }); + + const output = formatCodeSearchResults(payload); + + expect(output).toContain('[1] No space. (python)'); + expect(output).not.toContain('package:'); + }); + + it('separates multiple code results with blank lines', () => { + const payload = JSON.stringify({ + results: [ + { description: 'First sample', language: 'python', codeSnippet: 'print("hello")' }, + { description: 'Second sample', language: 'python', codeSnippet: 'print("world")' }, + ], + }); + + const output = formatCodeSearchResults(payload); + + expect(output).toContain('[1] First sample (python)'); + expect(output).toContain('[2] Second sample (python)'); + expect(output).toContain('\n\n[2]'); + }); + + it('omits language suffix when language is not present', () => { + const payload = JSON.stringify({ + results: [{ description: 'No lang', codeSnippet: 'some code' }], + }); + + const output = formatCodeSearchResults(payload); + + expect(output).toBe(['[1] No lang', '', 'some code'].join('\n')); + }); + + it('uses link field for the URL line', () => { + const payload = JSON.stringify({ + results: [{ description: 'With link', link: 'https://example.com/code', codeSnippet: 'x = 1' }], + }); + + const output = formatCodeSearchResults(payload); + + expect(output).toContain(' https://example.com/code'); + }); +}); diff --git a/scripts/validate-repo.ps1 b/scripts/validate-repo.ps1 index fb1c44e4..4c96c210 100644 --- a/scripts/validate-repo.ps1 +++ b/scripts/validate-repo.ps1 @@ -1,7 +1,7 @@ #!/usr/bin/env pwsh <# .SYNOPSIS - Validates the repository structure for the Claude plugin, agent skills, MCP config, and companion CLI. + Validates the repository structure for the Claude plugin, agent skills, MCP config, and CLI. .DESCRIPTION This script validates that all required files and folders exist for: @@ -17,7 +17,7 @@ 3. MCP Configuration (.mcp.json) - Root-level MCP server configuration - 4. Companion CLI (cli/) + 4. CLI (cli/) - TypeScript source, tests, and package metadata for the in-repo Learn CLI Run this script to verify your changes before submitting a PR. @@ -124,10 +124,10 @@ if (Test-Path $mcpJsonPath) { } # ============================================================================ -# Validation 4: Companion CLI Structure -# The cli folder contains the open source companion CLI implementation +# Validation 4: CLI Structure +# The cli folder contains the open source CLI implementation # ============================================================================ -Write-ValidationHeader "Validating Companion CLI (cli/)" +Write-ValidationHeader "Validating CLI (cli/)" $cliDir = Join-Path $repoRoot "cli" if (-not (Test-Path $cliDir)) {