Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
185 changes: 185 additions & 0 deletions cli/skills/get-mslearn-docs/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
---
name: get-mslearn-docs
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

we should update our existing skills instead of adding new skill

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.
---

Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

This SKILL.md lives under cli/skills/..., but the repo validation script (and existing convention) expects skills under the root skills/<skill>/SKILL.md. As-is, this skill likely won’t be discovered/validated by tooling. Consider moving it to skills/get-mslearn-docs/SKILL.md (or updating the validation/discovery logic if cli/skills is intentional).

Suggested change
> NOTE: For repository tooling and validation to recognize this skill, the
> canonical location for this file is `skills/get-mslearn-docs/SKILL.md`.
> Move or copy this content there and update any references accordingly.

Copilot uses AI. Check for mistakes.
# 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 "<microsoft product or topic>"

```

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.

Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

Step 1 says to review results for contentUrl, but then it instructs the reader to pick an id like openai/chat / stripe/api, which appears unrelated to this CLI and inconsistent with the mslearn search payload shape. Consider removing the id guidance and focusing on selecting the appropriate contentUrl from the search results.

Suggested change
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.
Review the search results to find the most relevant document. Use its `contentUrl` in the next step. If nothing matches, try a broader or alternative search term.

Copilot uses AI. Check for mistakes.
## Step 2 — Fetch the doc

```bash

mslearn fetch <contentUrl>

```

To fetch only a specific section (saves tokens):


```bash

mslearn fetch "<contentUrl>" --section "<section name>"

```



To limit output length:



```bash

mslearn fetch "<contentUrl>" --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 "<microsoft product or topic>"

```



To filter by programming language:



```bash

mslearn code-search "<microsoft product or topic>" --language <programming 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 "<contentUrl>" "<note text>"
```

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 "<contentUrl>"
```

## 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 "<contentUrl>" --clear

```

## Quick reference

| Goal | Command |

|------|---------|

| Search docs | `mslearn search "<microsoft product or topic>"` |

| Fetch a doc | `mslearn fetch "<contentUrl>"` |
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

The “Quick reference” table is not valid Markdown table syntax (it uses || prefixes and has extra blank | lines), so it likely won’t render as a table. Consider rewriting it using standard Markdown table formatting (| Goal | Command | header + separator row + data rows).

Copilot uses AI. Check for mistakes.

| Fetch one section | `mslearn fetch "<contentUrl>" --section "<section name>"` |

| Limit output size | `mslearn fetch "<contentUrl>" --max-chars 3000` |

| Search code samples | `mslearn code-search "<microsoft product or topic>"` |

| Filter by language | `mslearn code-search "<microsoft product or topic>" --language <programming language>` |

| Save a note | `mslearn annotate "<contentUrl>" "<note text>"` |

| View a note | `mslearn annotate "<contentUrl>"` |

| List all notes | `mslearn annotate --list` |

| Remove a note | `mslearn annotate "<contentUrl>" --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 `<contentUrl>` 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 <url>` flag



71 changes: 71 additions & 0 deletions cli/src/commands/annotate.ts
Original file line number Diff line number Diff line change
@@ -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: <url>. Usage: mslearn annotate <url> <note> | mslearn annotate <url> --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}.`));
});
}
3 changes: 3 additions & 0 deletions cli/src/context.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand All @@ -21,5 +23,6 @@ export function createDefaultContext(version: string): CliContext {
},
fetchImpl: globalThis.fetch.bind(globalThis) as typeof fetch,
createClient: (options) => createLearnCliClient(options),
createAnnotationStore: () => createFileAnnotationStore(),
};
}
2 changes: 2 additions & 0 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -40,6 +41,7 @@ export function createProgram(context: CliContext): Command {
registerFetchCommand(program, context);
registerCodeSearchCommand(program, context);
registerDoctorCommand(program, context);
registerAnnotateCommand(program, context);

return program;
}
Expand Down
95 changes: 95 additions & 0 deletions cli/src/utils/annotations.ts
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

URL-to-filename collisionsurlToFilename replaces all non-alphanumeric chars (except . and -) with _, so distinct URLs can silently map to the same file. For example, https://learn.microsoft.com/a-b and https://learn.microsoft.com/a_b both produce https___learn.microsoft.com_a_b.json. This causes one annotation to overwrite another with no warning. Consider using a hash-based key (e.g. crypto.createHash('sha256').update(url).digest('hex')) to avoid collisions.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Filename length limits — Long Microsoft Learn URLs can easily produce filenames exceeding the 255-character OS limit (Windows and most Linux filesystems), which would throw an ENAMETOOLONG error. Hashing the URL (as suggested above) would also solve this since the output length is fixed.

return url.replace(/[^a-zA-Z0-9.-]/g, '_') + '.json';
}
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

urlToFilename() derives the filename by replacing non-alphanumerics with _, which can cause collisions (different URLs mapping to the same filename) and can exceed filesystem filename limits for long URLs/query strings. This can lead to overwriting the wrong annotation or write failures. Consider using a stable hash of the normalized URL (optionally with a short readable prefix) as the filename instead of (or in addition to) a fully-sanitized URL string.

Copilot uses AI. Check for mistakes.

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;
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

clear() returns false for any error (including permission errors, EISDIR, etc.), which will make the CLI report "No annotation found" even when the file exists but couldn't be removed. Consider only returning false on ENOENT and surfacing other filesystem errors (e.g., by throwing an OperationError or letting the exception propagate) so the user isn't misled.

Suggested change
} catch {
return false;
} catch (err) {
const error = err as NodeJS.ErrnoException;
if (error && error.code === 'ENOENT') {
// File does not exist: report as "not cleared" without treating as an error.
return false;
}
// Surface other filesystem errors so callers aren't misled.
throw err;

Copilot uses AI. Check for mistakes.
}
}

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 [];
Comment thread
hejingkan2005 marked this conversation as resolved.
Outdated
}
}
}
Loading
Loading