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)) {