From c7dadc38c9454532ec0f0a805e086c6a408708a0 Mon Sep 17 00:00:00 2001 From: "FAREAST\\jahe" Date: Thu, 12 Mar 2026 16:13:39 +0800 Subject: [PATCH 1/2] - add SKILL.md and annotate command --- cli/skills/get-mslearn-docs/SKILL.md | 185 +++++++++++++++++++++++++++ cli/src/commands/annotate.ts | 71 ++++++++++ cli/src/context.ts | 3 + cli/src/index.ts | 2 + cli/src/utils/annotations.ts | 95 ++++++++++++++ cli/test/unit/annotations.test.ts | 111 ++++++++++++++++ cli/test/unit/cli.test.ts | 129 +++++++++++++++++++ 7 files changed, 596 insertions(+) create mode 100644 cli/skills/get-mslearn-docs/SKILL.md create mode 100644 cli/src/commands/annotate.ts create mode 100644 cli/src/utils/annotations.ts create mode 100644 cli/test/unit/annotations.test.ts diff --git a/cli/skills/get-mslearn-docs/SKILL.md b/cli/skills/get-mslearn-docs/SKILL.md new file mode 100644 index 00000000..22834097 --- /dev/null +++ b/cli/skills/get-mslearn-docs/SKILL.md @@ -0,0 +1,185 @@ +--- +name: get-mslearn-docs +description: > + Use this skill when you need documentation from Microsoft Learn before writing + code — for example, "use Azure Functions", "configure Cosmos DB", "set up App + Service", or any time the user asks you to work with a Microsoft technology + and you need current reference material. Fetch the docs with mslearn before + answering, rather than relying on training knowledge. +--- + +# Get Microsoft Learn Docs via mslearn + +When you need documentation for a Microsoft technology, fetch it with the + +`mslearn` CLI rather than guessing from training data. This gives you the + +current, correct content straight from Microsoft Learn. + +## Step 1 — Search for the right doc + +```bash + +mslearn search "" + +``` + +Review the search results to find the most relevant document `contentUrl`. If nothing matches, try a broader term. + +Pick the best-matching `id` from the results (e.g. `openai/chat`, `anthropic/sdk`, +`stripe/api`). If nothing matches, try a broader term. + +## Step 2 — Fetch the doc + +```bash + +mslearn fetch + +``` + +To fetch only a specific section (saves tokens): + + +```bash + +mslearn fetch "" --section "
" + +``` + + + +To limit output length: + + + +```bash + +mslearn fetch "" --max-chars 3000 + +``` + +## Step 3 — Use the docs + +Read the fetched content and use it to write accurate code or answer the question. + +Do not rely on memorized API shapes — use what the docs say. + +## Step 4 — Search for code samples + +If you need working code examples, search the official Microsoft code samples: + + + +```bash + +mslearn code-search "" + +``` + + + +To filter by programming language: + + + +```bash + +mslearn code-search "" --language + +``` + +## Step 5 — Annotate what you learned + +**ALWAYS perform this step before finishing.** Review what you learned and ask yourself: + +1. Did the docs contain any surprising behavior, version-specific caveats, or + non-obvious prerequisites? +2. Did you find that the docs were misleading, incomplete, or required combining + multiple pages to get a working answer? +3. Is there project-specific context (e.g., "we use .NET 8, not 6") that would + help future sessions? + +If ANY of the above apply, save an annotation: + +```bash +mslearn annotate "" "" +``` + +Annotations are local, persist across sessions, and are keyed by URL. Keep notes +concise and actionable. Don't repeat what's already in the doc. + +If none apply, explicitly state: "No annotation needed — docs were +straightforward." + +To view an existing annotation: + +```bash +mslearn annotate "" +``` + +## Step 6 — Review and manage annotations + +List all saved annotations: + + + +```bash + +mslearn annotate --list + +``` + + + +Remove an annotation that is no longer relevant: + + + +```bash + +mslearn annotate "" --clear + +``` + +## Quick reference + +| Goal | Command | + +|------|---------| + +| Search docs | `mslearn search ""` | + +| Fetch a doc | `mslearn fetch ""` | + +| Fetch one section | `mslearn fetch "" --section "
"` | + +| Limit output size | `mslearn fetch "" --max-chars 3000` | + +| Search code samples | `mslearn code-search ""` | + +| Filter by language | `mslearn code-search "" --language ` | + +| Save a note | `mslearn annotate "" ""` | + +| View a note | `mslearn annotate ""` | + +| List all notes | `mslearn annotate --list` | + +| Remove a note | `mslearn annotate "" --clear` | + +| Check connectivity | `mslearn doctor` | + +| Check (JSON output) | `mslearn doctor --format json` | + + + +## Notes + +- All docs are fetched from the Microsoft Learn MCP server at `https://learn.microsoft.com/api/mcp` +- The `` argument must be a valid Microsoft Learn URL +- Use `--section` with `fetch` to reduce token usage when you only need part of a page +- Use `mslearn doctor` to verify that your environment and connectivity are working +- Override the endpoint with `MSLEARN_ENDPOINT` env var or `--endpoint ` flag + + + diff --git a/cli/src/commands/annotate.ts b/cli/src/commands/annotate.ts new file mode 100644 index 00000000..f2526b2f --- /dev/null +++ b/cli/src/commands/annotate.ts @@ -0,0 +1,71 @@ +import { Command } from 'commander'; + +import type { CliContext } from '../context.js'; +import { normalizeUrl } from '../utils/options.js'; +import { ensureTrailingNewline } from '../utils/text.js'; +import { UsageError } from '../utils/errors.js'; + +interface AnnotateCommandOptions { + clear?: boolean; + list?: boolean; +} + +export function registerAnnotateCommand(program: Command, context: CliContext): void { + program + .command('annotate') + .description('Attach a local note to a Microsoft Learn URL. Notes persist across sessions.') + .argument('[url]', 'Microsoft Learn document URL.') + .argument('[note]', 'Note text to attach to the URL.') + .option('--clear', 'Remove the annotation for this URL.') + .option('--list', 'List all saved annotations.') + .action((url: string | undefined, note: string | undefined, options: AnnotateCommandOptions) => { + const store = context.createAnnotationStore(); + + if (options.list) { + const annotations = store.list(); + if (annotations.length === 0) { + context.writeOut(ensureTrailingNewline('No annotations.')); + return; + } + const lines: string[] = []; + for (const a of annotations) { + lines.push(`${a.url} (${a.updatedAt})`); + lines.push(` ${a.note}`); + lines.push(''); + } + context.writeOut(ensureTrailingNewline(lines.join('\n'))); + return; + } + + if (!url) { + throw new UsageError( + 'Missing required argument: . Usage: mslearn annotate | mslearn annotate --clear | mslearn annotate --list', + ); + } + + const normalizedUrl = normalizeUrl(url); + + if (options.clear) { + const removed = store.clear(normalizedUrl); + if (removed) { + context.writeOut(ensureTrailingNewline(`Annotation cleared for ${normalizedUrl}.`)); + } else { + context.writeOut(ensureTrailingNewline(`No annotation found for ${normalizedUrl}.`)); + } + return; + } + + if (!note) { + const existing = store.read(normalizedUrl); + if (existing) { + context.writeOut(ensureTrailingNewline(`${existing.url} (${existing.updatedAt})\n${existing.note}`)); + } else { + context.writeOut(ensureTrailingNewline(`No annotation for ${normalizedUrl}.`)); + } + return; + } + + const annotation = store.write(normalizedUrl, note); + context.writeOut(ensureTrailingNewline(`Annotation saved for ${annotation.url}.`)); + }); +} diff --git a/cli/src/context.ts b/cli/src/context.ts index ab8c04b6..b663f61a 100644 --- a/cli/src/context.ts +++ b/cli/src/context.ts @@ -1,4 +1,5 @@ import { createLearnCliClient, type LearnCliClientLike, type LearnClientOptions } from './mcp/client.js'; +import { createFileAnnotationStore, type AnnotationStore } from './utils/annotations.js'; export interface CliContext { env: NodeJS.ProcessEnv; @@ -7,6 +8,7 @@ export interface CliContext { writeErr: (value: string) => void; fetchImpl: typeof fetch; createClient: (options: LearnClientOptions) => LearnCliClientLike; + createAnnotationStore: () => AnnotationStore; } export function createDefaultContext(version: string): CliContext { @@ -21,5 +23,6 @@ export function createDefaultContext(version: string): CliContext { }, fetchImpl: globalThis.fetch.bind(globalThis) as typeof fetch, createClient: (options) => createLearnCliClient(options), + createAnnotationStore: () => createFileAnnotationStore(), }; } diff --git a/cli/src/index.ts b/cli/src/index.ts index 09cc2744..13f16838 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -5,6 +5,7 @@ import { createRequire } from 'node:module'; import { realpathSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; +import { registerAnnotateCommand } from './commands/annotate.js'; import { registerCodeSearchCommand } from './commands/code-search.js'; import { registerDoctorCommand } from './commands/doctor.js'; import { registerFetchCommand } from './commands/fetch.js'; @@ -40,6 +41,7 @@ export function createProgram(context: CliContext): Command { registerFetchCommand(program, context); registerCodeSearchCommand(program, context); registerDoctorCommand(program, context); + registerAnnotateCommand(program, context); return program; } diff --git a/cli/src/utils/annotations.ts b/cli/src/utils/annotations.ts new file mode 100644 index 00000000..ed6c1838 --- /dev/null +++ b/cli/src/utils/annotations.ts @@ -0,0 +1,95 @@ +import { mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import envPaths from 'env-paths'; + +export interface Annotation { + url: string; + note: string; + updatedAt: string; +} + +export interface AnnotationStore { + read(url: string): Annotation | undefined; + write(url: string, note: string): Annotation; + clear(url: string): boolean; + list(): Annotation[]; +} + +interface FileAnnotationStoreOptions { + annotationsDir?: string; + now?: () => number; +} + +export function getDefaultAnnotationsDir(): string { + const paths = envPaths('mslearn', { suffix: '' }); + return join(paths.data, 'annotations'); +} + +export function createFileAnnotationStore(options: FileAnnotationStoreOptions = {}): AnnotationStore { + return new FileAnnotationStore(options); +} + +function urlToFilename(url: string): string { + return url.replace(/[^a-zA-Z0-9.-]/g, '_') + '.json'; +} + +class FileAnnotationStore implements AnnotationStore { + private readonly annotationsDir: string; + private readonly now: () => number; + + constructor(options: FileAnnotationStoreOptions) { + this.annotationsDir = options.annotationsDir ?? getDefaultAnnotationsDir(); + this.now = options.now ?? Date.now; + } + + read(url: string): Annotation | undefined { + try { + const filePath = join(this.annotationsDir, urlToFilename(url)); + const raw = readFileSync(filePath, 'utf8'); + return JSON.parse(raw) as Annotation; + } catch { + return undefined; + } + } + + write(url: string, note: string): Annotation { + mkdirSync(this.annotationsDir, { recursive: true }); + const annotation: Annotation = { + url, + note, + updatedAt: new Date(this.now()).toISOString(), + }; + const filePath = join(this.annotationsDir, urlToFilename(url)); + writeFileSync(filePath, JSON.stringify(annotation, null, 2), 'utf8'); + return annotation; + } + + clear(url: string): boolean { + try { + const filePath = join(this.annotationsDir, urlToFilename(url)); + unlinkSync(filePath); + return true; + } catch { + return false; + } + } + + list(): Annotation[] { + try { + const files = readdirSync(this.annotationsDir).filter((f) => f.endsWith('.json')); + const results: Annotation[] = []; + for (const file of files) { + try { + const raw = readFileSync(join(this.annotationsDir, file), 'utf8'); + results.push(JSON.parse(raw) as Annotation); + } catch { + // skip malformed files + } + } + return results; + } catch { + return []; + } + } +} diff --git a/cli/test/unit/annotations.test.ts b/cli/test/unit/annotations.test.ts new file mode 100644 index 00000000..c299eed8 --- /dev/null +++ b/cli/test/unit/annotations.test.ts @@ -0,0 +1,111 @@ +import { mkdtempSync, readFileSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { createFileAnnotationStore } from '../../src/utils/annotations.js'; + +function createTempStore(now?: () => number) { + const dir = mkdtempSync(join(tmpdir(), 'mslearn-annotations-')); + return { + store: createFileAnnotationStore({ annotationsDir: dir, now }), + dir, + }; +} + +describe('annotation store', () => { + it('writes and reads an annotation', () => { + const { store } = createTempStore(() => 1_000); + + const result = store.write('https://learn.microsoft.com/en-us/azure/functions/', 'Needs raw body for webhooks'); + + expect(result.url).toBe('https://learn.microsoft.com/en-us/azure/functions/'); + expect(result.note).toBe('Needs raw body for webhooks'); + expect(result.updatedAt).toBe('1970-01-01T00:00:01.000Z'); + + const read = store.read('https://learn.microsoft.com/en-us/azure/functions/'); + expect(read).toEqual(result); + }); + + it('returns undefined for a missing annotation', () => { + const { store } = createTempStore(); + + expect(store.read('https://learn.microsoft.com/nonexistent')).toBeUndefined(); + }); + + it('overwrites an existing annotation', () => { + const { store } = createTempStore(); + + store.write('https://learn.microsoft.com/test', 'first note'); + store.write('https://learn.microsoft.com/test', 'updated note'); + + const result = store.read('https://learn.microsoft.com/test'); + expect(result?.note).toBe('updated note'); + }); + + it('clears an existing annotation', () => { + const { store } = createTempStore(); + + store.write('https://learn.microsoft.com/test', 'some note'); + const removed = store.clear('https://learn.microsoft.com/test'); + + expect(removed).toBe(true); + expect(store.read('https://learn.microsoft.com/test')).toBeUndefined(); + }); + + it('returns false when clearing a non-existent annotation', () => { + const { store } = createTempStore(); + + expect(store.clear('https://learn.microsoft.com/nonexistent')).toBe(false); + }); + + it('lists all annotations', () => { + const { store } = createTempStore(); + + store.write('https://learn.microsoft.com/a', 'note A'); + store.write('https://learn.microsoft.com/b', 'note B'); + + const list = store.list(); + expect(list).toHaveLength(2); + + const urls = list.map((a) => a.url).sort(); + expect(urls).toEqual([ + 'https://learn.microsoft.com/a', + 'https://learn.microsoft.com/b', + ]); + }); + + it('returns an empty list when no annotations exist', () => { + const { store } = createTempStore(); + + expect(store.list()).toEqual([]); + }); + + it('creates the annotations directory on first write', () => { + const tempBase = mkdtempSync(join(tmpdir(), 'mslearn-ann-parent-')); + const annotationsDir = join(tempBase, 'nested', 'annotations'); + const store = createFileAnnotationStore({ annotationsDir }); + + store.write('https://learn.microsoft.com/test', 'note'); + + const files = readdirSync(annotationsDir); + expect(files.length).toBe(1); + expect(files[0]).toMatch(/\.json$/); + }); + + it('stores annotations as valid JSON files on disk', () => { + const { store, dir } = createTempStore(() => 2_000); + + store.write('https://learn.microsoft.com/test', 'a note'); + + const files = readdirSync(dir).filter((f) => f.endsWith('.json')); + expect(files).toHaveLength(1); + + const raw = readFileSync(join(dir, files[0]!), 'utf8'); + const parsed = JSON.parse(raw); + expect(parsed.url).toBe('https://learn.microsoft.com/test'); + expect(parsed.note).toBe('a note'); + expect(parsed.updatedAt).toBe('1970-01-01T00:00:02.000Z'); + }); +}); diff --git a/cli/test/unit/cli.test.ts b/cli/test/unit/cli.test.ts index 27154530..d64acd2e 100644 --- a/cli/test/unit/cli.test.ts +++ b/cli/test/unit/cli.test.ts @@ -1,8 +1,13 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { describe, expect, it, vi } from 'vitest'; import { runCli } from '../../src/index.js'; import type { CliContext } from '../../src/context.js'; import type { LearnCliClientLike } from '../../src/mcp/client.js'; +import { createFileAnnotationStore } from '../../src/utils/annotations.js'; function createMockClient(overrides: Partial = {}): LearnCliClientLike { return { @@ -26,6 +31,7 @@ function createTestContext(client: LearnCliClientLike): { } { const stdout: string[] = []; const stderr: string[] = []; + const annotationsDir = mkdtempSync(join(tmpdir(), 'mslearn-test-annotations-')); return { context: { @@ -39,6 +45,7 @@ function createTestContext(client: LearnCliClientLike): { }, fetchImpl: vi.fn(async () => new Response(null, { status: 405 })) as unknown as typeof fetch, createClient: () => client, + createAnnotationStore: () => createFileAnnotationStore({ annotationsDir }), }, stdout, stderr, @@ -133,3 +140,125 @@ describe('runCli', () => { expect(stderr.join('')).toContain('missing required argument'); }); }); + +describe('annotate command', () => { + it('saves and retrieves an annotation for a URL', async () => { + const client = createMockClient(); + const { context, stdout } = createTestContext(client); + + const exitCode = await runCli( + ['node', 'mslearn', 'annotate', 'https://learn.microsoft.com/en-us/azure/functions/', 'Needs raw body for webhooks'], + context, + ); + + expect(exitCode).toBe(0); + expect(stdout.join('')).toContain('Annotation saved for'); + expect(stdout.join('')).toContain('https://learn.microsoft.com/en-us/azure/functions/'); + }); + + it('reads an existing annotation when no note is provided', async () => { + const client = createMockClient(); + const { context, stdout } = createTestContext(client); + + await runCli( + ['node', 'mslearn', 'annotate', 'https://learn.microsoft.com/en-us/azure/functions/', 'Webhook note'], + context, + ); + stdout.length = 0; + + const exitCode = await runCli( + ['node', 'mslearn', 'annotate', 'https://learn.microsoft.com/en-us/azure/functions/'], + context, + ); + + expect(exitCode).toBe(0); + expect(stdout.join('')).toContain('Webhook note'); + }); + + it('shows a message for a non-existent annotation', async () => { + const client = createMockClient(); + const { context, stdout } = createTestContext(client); + + const exitCode = await runCli( + ['node', 'mslearn', 'annotate', 'https://learn.microsoft.com/en-us/nonexistent'], + context, + ); + + expect(exitCode).toBe(0); + expect(stdout.join('')).toContain('No annotation for'); + }); + + it('clears an existing annotation with --clear', async () => { + const client = createMockClient(); + const { context, stdout } = createTestContext(client); + + await runCli( + ['node', 'mslearn', 'annotate', 'https://learn.microsoft.com/en-us/azure/functions/', 'temp note'], + context, + ); + stdout.length = 0; + + const exitCode = await runCli( + ['node', 'mslearn', 'annotate', 'https://learn.microsoft.com/en-us/azure/functions/', '--clear'], + context, + ); + + expect(exitCode).toBe(0); + expect(stdout.join('')).toContain('Annotation cleared for'); + }); + + it('reports when --clear targets a non-existent annotation', async () => { + const client = createMockClient(); + const { context, stdout } = createTestContext(client); + + const exitCode = await runCli( + ['node', 'mslearn', 'annotate', 'https://learn.microsoft.com/en-us/nonexistent', '--clear'], + context, + ); + + expect(exitCode).toBe(0); + expect(stdout.join('')).toContain('No annotation found for'); + }); + + it('lists all annotations with --list', async () => { + const client = createMockClient(); + const { context, stdout } = createTestContext(client); + + await runCli( + ['node', 'mslearn', 'annotate', 'https://learn.microsoft.com/en-us/azure/functions/', 'functions note'], + context, + ); + await runCli( + ['node', 'mslearn', 'annotate', 'https://learn.microsoft.com/en-us/azure/storage/', 'storage note'], + context, + ); + stdout.length = 0; + + const exitCode = await runCli(['node', 'mslearn', 'annotate', '--list'], context); + + expect(exitCode).toBe(0); + const output = stdout.join(''); + expect(output).toContain('functions note'); + expect(output).toContain('storage note'); + }); + + it('shows a message when --list finds no annotations', async () => { + const client = createMockClient(); + const { context, stdout } = createTestContext(client); + + const exitCode = await runCli(['node', 'mslearn', 'annotate', '--list'], context); + + expect(exitCode).toBe(0); + expect(stdout.join('')).toContain('No annotations.'); + }); + + it('returns a usage error when no URL is provided without --list', async () => { + const client = createMockClient(); + const { context, stderr } = createTestContext(client); + + const exitCode = await runCli(['node', 'mslearn', 'annotate'], context); + + expect(exitCode).toBe(2); + expect(stderr.join('')).toContain('Missing required argument'); + }); +}); From c0507336581db95c7b8badd7f44131ed551469f9 Mon Sep 17 00:00:00 2001 From: Jingkan He Date: Tue, 17 Mar 2026 09:24:49 +0800 Subject: [PATCH 2/2] Update cli/src/utils/annotations.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cli/src/utils/annotations.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cli/src/utils/annotations.ts b/cli/src/utils/annotations.ts index ed6c1838..898f6986 100644 --- a/cli/src/utils/annotations.ts +++ b/cli/src/utils/annotations.ts @@ -88,8 +88,14 @@ class FileAnnotationStore implements AnnotationStore { } } return results; - } catch { - return []; + } catch (error: unknown) { + const err = error as NodeJS.ErrnoException; + if (err && err.code === 'ENOENT') { + // Treat missing annotations directory as "no annotations". + return []; + } + // Surface other I/O errors so callers can diagnose issues. + throw error; } } }