From dd017e0445b56389ad39fb070029b20d7fd6f531 Mon Sep 17 00:00:00 2001 From: anderson-joyle Date: Tue, 8 Sep 2026 13:37:35 -0500 Subject: [PATCH 1/4] Add /add-skill command to acquire agent skills from the gallery Adds a new slash command and supporting Node script that let a developer acquire a Copilot Studio agent skill by either uploading a local SKILL.md/.zip or picking from the Power CAT cat-agent-skills gallery. - scripts/add-skill.js: zero-dependency Node script with 'list' (enumerate gallery submissions via the GitHub Trees API + raw metadata.json, filter to Copilot Studio, sort by name) and 'download' (materialize SKILL.md plus scripts/references/assets, and the prebuilt .zip when present). - commands/add-skill.md: /add-skill orchestration for source choice, listing, picking, downloading and reporting. Import into a Copilot Studio agent project is intentionally out of scope for this change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e8d082ed-ab52-4307-bdda-b4fb86c32cdd --- commands/add-skill.md | 101 ++++++++++++++++ scripts/add-skill.js | 265 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 commands/add-skill.md create mode 100644 scripts/add-skill.js diff --git a/commands/add-skill.md b/commands/add-skill.md new file mode 100644 index 0000000..af0369d --- /dev/null +++ b/commands/add-skill.md @@ -0,0 +1,101 @@ +--- +description: Add an Agent Skill to work with locally - either uploaded from your drive (a SKILL.md or .zip) or picked from the Power CAT "Cat Agent Skills" gallery, which is downloaded into a local folder. Does not yet import the skill into a Copilot Studio agent project. +argument-hint: Optional skill name/slug or a local path to a SKILL.md / .zip +allowed-tools: Bash(node *add-skill.js*), Read, Glob, Grep +--- + +# Add a Copilot Studio Agent Skill + +You help the user obtain an **Agent Skill** to add to a Copilot Studio agent. A skill is either +**uploaded from the user's local drive** (a `SKILL.md` file or a `.zip` bundle) or **selected from +the Power CAT "Cat Agent Skills" gallery** (`https://microsoft.github.io/cat-agent-skills/`, +source `github.com/microsoft/cat-agent-skills`) and downloaded locally. + +**Scope for now:** this command only *acquires* the skill (validates a local file, or downloads a +gallery skill into a local folder). **Importing** the skill into a Copilot Studio agent workspace +(materializing it under `behaviors/` and pushing with `pac copilot push`) is intentionally **out of +scope** and must not be attempted here. End by telling the user where the skill is and that import is +a later step. + +Initial request: $ARGUMENTS + +--- + +## 1. Locate the helper script (non-blocking) + +Resolve `scripts/add-skill.js` inside this installed plugin. In order: + +1. Read `path.join(os.homedir(), '.copilot-studio-cli', 'plugin-paths.json')` and use its + `pluginRoot` -> `path.join(pluginRoot, 'scripts', 'add-skill.js')`. +2. If that file is unavailable, fall back to `${CLAUDE_PLUGIN_ROOT}/scripts/add-skill.js` when the env + var is set. +3. Otherwise `Glob: **/scripts/add-skill.js` under the plugin directory. + +Use this absolute path for every `node` invocation below. + +## 2. Choose the source (blocking) + +If the initial request already makes the source obvious, skip the question: + +- A path ending in `.md` or `.zip`, or an existing file path -> **local upload** (step 3). +- A skill name/slug that is not a path -> treat as a **gallery** pick (step 4), matching it against + the listed skills. + +Otherwise ask the user to choose: + +- **Upload from local drive** - they already have a `SKILL.md` or a `.zip`. +- **Select from the gallery** - browse the Cat Agent Skills catalog. + +## 3. Local upload + +1. Ask for the full path if it was not provided. +2. Validate it exists and is a single `SKILL.md` (Markdown) or a `.zip`. If it is neither, tell the + user what is accepted and stop. +3. Confirm the resolved absolute path and that the file is ready. Do **not** import it (out of scope). + +## 4. Select from the gallery + +1. List the available skills: + + ```bash + node "" list + ``` + + By default this returns only skills whose `platforms` include **Copilot Studio**, already sorted + by display name. (Add `--all` only if the user explicitly wants every platform.) The JSON is + `{ ok, count, skills: [{ slug, name, description, platforms, tags, hasBundle }] }`. + +2. Present the skills to the user as a **numbered list sorted by name**, each line showing the + **name** and a short **description**. You may note which ship extra files (`hasBundle: true`, + i.e. scripts/references) versus a single `SKILL.md`. Ask the user to pick one (by number or name). + If the initial request named a skill, resolve it to a `slug` and confirm the match. + +3. Download the chosen skill. Ask for a destination folder if the user has a preference; otherwise + default to the current working directory. + + ```bash + node "" download --slug "" --dest "" + ``` + + The result JSON is `{ ok, slug, name, hasBundle, dir, zip, files }`. The unpacked payload + (`SKILL.md` plus any `scripts/`, `references/`, `assets/`) is written to `//`, and when + a prebuilt bundle exists, `/.zip` is saved too. + +## 5. Report + +Tell the user, concisely: + +- The skill name and slug. +- Where it was saved - the unpacked folder path, and the `.zip` path when one was produced. +- Whether it is a single-`SKILL.md` skill or a bundle with extra files. +- That **import into a Copilot Studio agent is not done yet** - it is a separate, upcoming step. + +## Error handling + +- The script prints `{ "ok": false, "error": "..." }` and exits non-zero on failure. Surface the + `error` message. +- A GitHub tree "truncated" error, rate-limit, or network failure from `list`/`download` is + transient - report it and offer to retry. Setting `GITHUB_TOKEN` raises the API limit for the one + tree call, but is not normally required. +- If the user picks a non-skill catalog entry (a Scout automation or a legacy `.zip`), `download` + refuses it with a clear message; relay that and suggest picking an actual skill. diff --git a/scripts/add-skill.js b/scripts/add-skill.js new file mode 100644 index 0000000..5615d4f --- /dev/null +++ b/scripts/add-skill.js @@ -0,0 +1,265 @@ +#!/usr/bin/env node +'use strict'; + +/* + * add-skill.js - discover and download Agent Skills from the Power CAT + * "Cat Agent Skills" gallery (github.com/microsoft/cat-agent-skills) so they can + * be added to a Copilot Studio agent. + * + * Commands: + * list [--all] [--json] + * Enumerate gallery submissions and print the skills as JSON, sorted by + * display name. By default only skills whose metadata `platforms` include + * "Copilot Studio" are returned; pass --all to include every skill. + * + * download --slug --dest [--json] + * Download one skill into . The unpacked payload (SKILL.md plus any + * scripts/, references/, assets/) is written to //, and when the + * gallery publishes a prebuilt bundle for the skill, /.zip is + * saved too. + * + * Zero runtime dependencies: uses the Node >=18 global fetch and the standard + * library only. Optional GITHUB_TOKEN / GH_TOKEN raises the api.github.com rate + * limit for the single tree call. + * + * NOTE: importing the downloaded skill into a Copilot Studio agent workspace + * (materializing it under behaviors/ and pushing with `pac copilot push`) is a + * separate, out-of-scope step and is intentionally not performed here. + */ + +const fs = require('fs'); +const path = require('path'); + +const REPO = 'microsoft/cat-agent-skills'; +const BRANCH = 'main'; +const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/${BRANCH}`; +const API_BASE = `https://api.github.com/repos/${REPO}`; +const PAGES_BASE = 'https://microsoft.github.io/cat-agent-skills'; + +// Sidecar files live next to the payload but are never part of the skill itself. +const SIDECARS = new Set(['metadata.json', 'metadata.yaml', 'metadata.yml', 'README.md']); +// Scaffolding folders in the gallery that are not real submissions. +const TEMPLATES = new Set(['_template', '_template-automation']); + +function ghHeaders() { + const headers = { + 'User-Agent': 'mcs-assistant-add-skill', + Accept: 'application/vnd.github+json', + }; + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + if (token) headers.Authorization = `Bearer ${token}`; + return headers; +} + +async function fetchText(url, headers) { + const res = await fetch(url, headers ? { headers } : undefined); + if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`); + return res.text(); +} + +async function fetchJson(url, headers) { + return JSON.parse(await fetchText(url, headers)); +} + +async function fetchBuffer(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`); + return Buffer.from(await res.arrayBuffer()); +} + +// Bounded-concurrency map so `list` does not open ~100 sockets at once. +async function mapLimit(items, limit, worker) { + const results = new Array(items.length); + let next = 0; + const runners = new Array(Math.min(limit, items.length)).fill(0).map(async () => { + while (next < items.length) { + const idx = next++; + results[idx] = await worker(items[idx], idx); + } + }); + await Promise.all(runners); + return results; +} + +// One recursive tree call returns every path in the repo; group blobs by slug. +async function getSubmissions() { + const data = await fetchJson(`${API_BASE}/git/trees/${BRANCH}?recursive=1`, ghHeaders()); + if (data.truncated) { + throw new Error('GitHub tree response was truncated; cannot enumerate submissions reliably.'); + } + const map = new Map(); // slug -> string[] (paths relative to submissions//) + for (const entry of data.tree) { + if (entry.type !== 'blob') continue; + const m = entry.path.match(/^submissions\/([^/]+)\/(.+)$/); + if (!m) continue; + const [, slug, rel] = m; + if (TEMPLATES.has(slug)) continue; + if (!map.has(slug)) map.set(slug, []); + map.get(slug).push(rel); + } + return map; +} + +function topName(rel) { + return rel.split('/')[0]; +} + +function isSkillFile(rel) { + return rel === 'SKILL.md' || rel === 'skill.md'; +} + +// Files that make up the downloadable skill (everything except sidecars). +function payloadFiles(files) { + return files.filter((f) => !SIDECARS.has(topName(f))); +} + +function classify(files) { + const top = files.filter((f) => !f.includes('/')); + const hasSkill = files.some(isSkillFile); + const hasLegacyZip = top.some((f) => f.toLowerCase().endsWith('.zip')); + const hasRootJson = top.some((f) => f.toLowerCase().endsWith('.json') && !/^metadata\.json$/i.test(f)); + + let type = 'unknown'; + if (hasSkill) type = 'skill'; + else if (hasLegacyZip) type = 'legacy-zip'; + else if (hasRootJson) type = 'automation'; + + // A skill "has a bundle" when it ships payload files beyond SKILL.md. + const extras = payloadFiles(files).filter((f) => !isSkillFile(f)); + return { type, hasBundle: extras.length > 0 }; +} + +async function readMetadata(slug) { + try { + return JSON.parse(await fetchText(`${RAW_BASE}/submissions/${slug}/metadata.json`)); + } catch { + return null; + } +} + +async function listSkills({ all } = {}) { + const submissions = await getSubmissions(); + const slugs = [...submissions.keys()]; + + const rows = await mapLimit(slugs, 8, async (slug) => { + const info = classify(submissions.get(slug)); + const meta = info.type === 'skill' ? await readMetadata(slug) : null; + return { slug, info, meta }; + }); + + let skills = rows + .filter((r) => r.info.type === 'skill' && r.meta) + .map((r) => ({ + slug: r.slug, + name: r.meta.name || r.slug, + description: r.meta.description || '', + platforms: Array.isArray(r.meta.platforms) ? r.meta.platforms : [], + tags: Array.isArray(r.meta.tags) ? r.meta.tags : [], + hasBundle: r.info.hasBundle, + })); + + if (!all) { + skills = skills.filter((s) => s.platforms.some((p) => /copilot studio/i.test(p))); + } + + skills.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + return skills; +} + +async function downloadSkill(slug, dest) { + const submissions = await getSubmissions(); + if (!submissions.has(slug)) { + throw new Error(`Skill not found in gallery: "${slug}"`); + } + const files = submissions.get(slug); + const info = classify(files); + if (info.type !== 'skill') { + throw new Error(`"${slug}" is a ${info.type} entry, not an unpacked Agent Skill; cannot add it to a Copilot Studio agent.`); + } + + const skillDir = path.join(dest, slug); + fs.mkdirSync(skillDir, { recursive: true }); + + const saved = []; + const meta = await readMetadata(slug); + + // Always write the unpacked payload (SKILL.md + scripts/references/assets). + for (const rel of payloadFiles(files)) { + const buf = await fetchBuffer(`${RAW_BASE}/submissions/${slug}/${rel}`); + const target = path.join(skillDir, rel); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, buf); + saved.push(target); + } + + // When the gallery publishes a prebuilt bundle, also save the .zip. + let zipPath = null; + if (info.hasBundle) { + try { + const buf = await fetchBuffer(`${PAGES_BASE}/bundles/${slug}.zip`); + zipPath = path.join(dest, `${slug}.zip`); + fs.writeFileSync(zipPath, buf); + saved.push(zipPath); + } catch (e) { + // Non-fatal: the unpacked payload above is the source of truth. + zipPath = null; + } + } + + return { + slug, + name: meta && meta.name ? meta.name : slug, + hasBundle: info.hasBundle, + dir: skillDir, + zip: zipPath, + files: saved, + }; +} + +function parseArgs(argv) { + const args = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith('--')) { + const key = a.slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) { + args[key] = true; + } else { + args[key] = next; + i++; + } + } else { + args._.push(a); + } + } + return args; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const cmd = args._[0]; + + if (cmd === 'list') { + const skills = await listSkills({ all: !!args.all }); + process.stdout.write(JSON.stringify({ ok: true, count: skills.length, skills }, null, 2) + '\n'); + return; + } + + if (cmd === 'download') { + if (!args.slug) throw new Error('--slug is required for download.'); + const dest = path.resolve(typeof args.dest === 'string' ? args.dest : process.cwd()); + const result = await downloadSkill(args.slug, dest); + process.stdout.write(JSON.stringify({ ok: true, ...result }, null, 2) + '\n'); + return; + } + + throw new Error(`Unknown command: ${cmd || '(none)'}. Use "list" or "download".`); +} + +main().catch((err) => { + process.stdout.write(JSON.stringify({ ok: false, error: err && err.message ? err.message : String(err) }) + '\n'); + // Signal failure via exit code rather than process.exit(): a forced exit while + // fetch keep-alive sockets are still closing trips a libuv assertion on Windows. + process.exitCode = 1; +}); From 962abe8e7f24d4db45db175d315b6b1fb06e804a Mon Sep 17 00:00:00 2001 From: anderson-joyle Date: Mon, 14 Sep 2026 11:30:50 -0500 Subject: [PATCH 2/4] Add skill import into cloned agent workspaces, plus pretty gallery listing Extend the /add-skill command beyond acquiring a skill so it can also materialize it into a cloned Copilot Studio agent workspace, and make the gallery easier to browse in the terminal. scripts/add-skill.js: - New `import --src --workspace ` command that writes the skill under behaviors//: SKILL.md plus every payload file copied verbatim. By default it also emits portal-style .mcs.yml companions (an anchor skill.mcs.yml carrying the InlineAgentSkill identity, plus one .mcs.yml sidecar per non-manifest file for bundle skills) so the on-disk layout matches a Copilot Studio portal import. Schema names are prefixed with the agent schemaName read from settings.mcs.yml; when the prefix is unavailable or --no-sidecars is passed, it falls back to a bare behaviors/ skill and warns. Supports --name, --force, and --include-sidecars, and surfaces non-fatal workspace checks as warnings. - `list --pretty` renders a deterministic, fixed-width box table (with a Slug column and bundle marker) instead of JSON. - `download --dest` is now optional and defaults to a temp folder (/mcs-add-skill) so gallery downloads don't clutter the cwd. - Make skill sort order deterministic (code-point, slug tiebreaker). commands/add-skill.md: - Document the new acquire-then-import flow, the standalone source question, the --pretty table, temp-folder downloads, and the import step. Clarify that publishing to the cloud happens from the VS Code Copilot Studio extension - this command never pushes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- commands/add-skill.md | 114 +++++++++--- scripts/add-skill.js | 413 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 487 insertions(+), 40 deletions(-) diff --git a/commands/add-skill.md b/commands/add-skill.md index af0369d..b4131ec 100644 --- a/commands/add-skill.md +++ b/commands/add-skill.md @@ -1,5 +1,5 @@ --- -description: Add an Agent Skill to work with locally - either uploaded from your drive (a SKILL.md or .zip) or picked from the Power CAT "Cat Agent Skills" gallery, which is downloaded into a local folder. Does not yet import the skill into a Copilot Studio agent project. +description: Add an Agent Skill to a Copilot Studio agent - uploaded from your drive (a SKILL.md or .zip) or picked from the Power CAT "Cat Agent Skills" gallery - then optionally import it into a cloned agent workspace under behaviors/. argument-hint: Optional skill name/slug or a local path to a SKILL.md / .zip allowed-tools: Bash(node *add-skill.js*), Read, Glob, Grep --- @@ -11,11 +11,13 @@ You help the user obtain an **Agent Skill** to add to a Copilot Studio agent. A the Power CAT "Cat Agent Skills" gallery** (`https://microsoft.github.io/cat-agent-skills/`, source `github.com/microsoft/cat-agent-skills`) and downloaded locally. -**Scope for now:** this command only *acquires* the skill (validates a local file, or downloads a -gallery skill into a local folder). **Importing** the skill into a Copilot Studio agent workspace -(materializing it under `behaviors/` and pushing with `pac copilot push`) is intentionally **out of -scope** and must not be attempted here. End by telling the user where the skill is and that import is -a later step. +**Flow:** first *acquire* the skill (validate a local file, or download a gallery skill into a local +folder), then optionally *import* it into a cloned Copilot Studio agent workspace under +`behaviors//`. By default the import also writes the **portal-style `.mcs.yml` companions** (an +anchor `skill.mcs.yml` plus per-file sidecars for bundle skills) so the on-disk layout matches a +Copilot Studio portal import. Import only materializes files on disk; **publishing to the cloud is +done from the VS Code Copilot Studio extension** (Agent Changes view / sync push) afterward - this +command never pushes. Initial request: $ARGUMENTS @@ -41,54 +43,114 @@ If the initial request already makes the source obvious, skip the question: - A skill name/slug that is not a path -> treat as a **gallery** pick (step 4), matching it against the listed skills. -Otherwise ask the user to choose: +Otherwise ask the user to choose the source **as a single, standalone question** with exactly two +options: - **Upload from local drive** - they already have a `SKILL.md` or a `.zip`. - **Select from the gallery** - browse the Cat Agent Skills catalog. +Keep this question to the source choice only. Do **not** bundle any other field into it - in +particular, never add an upload-path field with "leave blank if downloading from the gallery" (or +similar) wording. Ask for a local path only after the user picks **Upload** (step 3); ask nothing +about paths on the **gallery** branch until it is time to pick a destination (step 4). + ## 3. Local upload 1. Ask for the full path if it was not provided. 2. Validate it exists and is a single `SKILL.md` (Markdown) or a `.zip`. If it is neither, tell the user what is accepted and stop. -3. Confirm the resolved absolute path and that the file is ready. Do **not** import it (out of scope). +3. Confirm the resolved absolute path. For a `SKILL.md`, its containing folder is the `--src` for + import (step 5). For a `.zip`, extract it to a folder first so `--src` points at the extracted + `SKILL.md` and any payload files. ## 4. Select from the gallery -1. List the available skills: +1. List the available skills as a **table** (box structure), which is the format to show the user: ```bash - node "" list + node "" list --pretty ``` - By default this returns only skills whose `platforms` include **Copilot Studio**, already sorted - by display name. (Add `--all` only if the user explicitly wants every platform.) The JSON is - `{ ok, count, skills: [{ slug, name, description, platforms, tags, hasBundle }] }`. + This renders a table with columns `#`, `◆` (bundle marker), `Name`, `Description`, and `Slug`, + already sorted by display name and limited to skills whose `platforms` include **Copilot Studio**. + The layout is **deterministic** - a fixed render width and stable sort mean the same gallery data + always produces the exact same table, regardless of terminal size. The `◆` marks a bundle skill + (ships extra `scripts/`, `references/`, or `assets/`); an empty cell is a single `SKILL.md`. Add + `--all` only if the user explicitly wants every platform. For programmatic use, plain + `node "" list` still emits JSON `{ ok, count, skills: [{ slug, name, description, + platforms, tags, hasBundle }] }`. -2. Present the skills to the user as a **numbered list sorted by name**, each line showing the - **name** and a short **description**. You may note which ship extra files (`hasBundle: true`, - i.e. scripts/references) versus a single `SKILL.md`. Ask the user to pick one (by number or name). - If the initial request named a skill, resolve it to a `slug` and confirm the match. +2. Present that table to the user (do not flatten it into a plain list), then ask them to pick one by + **number or name**. Map their choice to the exact `slug` from the table's `Slug` column for the + download. If the initial request already named a skill, resolve it to a `slug` and confirm the + match. -3. Download the chosen skill. Ask for a destination folder if the user has a preference; otherwise - default to the current working directory. +3. Download the chosen skill. **Do not ask the user where to save it** - omit `--dest` so it lands in + a temporary user folder automatically (`/mcs-add-skill/`): ```bash - node "" download --slug "" --dest "" + node "" download --slug "" ``` - The result JSON is `{ ok, slug, name, hasBundle, dir, zip, files }`. The unpacked payload - (`SKILL.md` plus any `scripts/`, `references/`, `assets/`) is written to `//`, and when - a prebuilt bundle exists, `/.zip` is saved too. + The result JSON is `{ ok, slug, name, hasBundle, dir, zip, files }`. Use the returned `dir` as the + `--src` when importing (step 5) - do not prompt for a location. The unpacked payload (`SKILL.md` + plus any `scripts/`, `references/`, `assets/`) is written to `dir`, and when a prebuilt bundle + exists, `/mcs-add-skill/.zip` is saved too. + +## 5. Import into a cloned agent workspace (optional) + +Offer to import the acquired skill into a **cloned Copilot Studio agent workspace** (a folder that +contains `settings.mcs.yml` + `agent.sync.yaml`, produced by the extension's Clone Agent command). If +the user does not have one yet, tell them to clone/attach an agent first, and stop after acquiring. + +Import writes the skill under `behaviors//`: the manifest as `SKILL.md` and every other payload +file (e.g. `scripts/`) copied verbatim. By default it then also emits the **portal-style `.mcs.yml` +companions** so the layout matches a Copilot Studio portal import: + +- an anchor `skill.mcs.yml` carrying the `InlineAgentSkill` identity (`componentName`, the manifest + `description`, a `schemaName`, `kind`, `authoringSource: Upload`), and +- for **bundle** skills (any payload file beyond `SKILL.md`), a `bundle` + `manifestSchemaName` on the + anchor plus one `.mcs.yml` sidecar next to every non-manifest payload file. Single-`SKILL.md` + skills get only the anchor (no `bundle`, no sidecars). + +Every component's schema name is prefixed with the agent `schemaName` read from the workspace +`settings.mcs.yml`. If that prefix cannot be determined - or you pass `--no-sidecars` - the import +falls back to a **bare `behaviors//` folder** and the extension synthesizes the companions on +the next workspace read / sync (a `warning` explains which happened). + +```bash +node "" import --src "" --workspace "" [--name ""] [--force] [--no-sidecars] +``` + +- `--src` is the unpacked skill folder (`//` from step 4, or the folder holding an + uploaded `SKILL.md`). It must contain a top-level `SKILL.md` (case-insensitive; written as + `SKILL.md`). +- `--workspace` is the cloned agent root. `--name` overrides the `behaviors/` folder name (defaults to + the source folder name, sanitized). `--force` overwrites an existing `behaviors//`. +- `--no-sidecars` skips companion generation and writes a bare skill (the pre-existing behavior). +- Gallery sidecars (`metadata.json`, `README.md`) are excluded by default; pass `--include-sidecars` + to keep them. Any `.mcs.yml` files already in `--src` are ignored and regenerated. + +The result JSON is `{ ok, folder, workspace, behaviorsDir, manifest, files, companions, schemaPrefix, warnings, next }`. +`companions` lists the `.mcs.yml` files written (empty when bare) and `schemaPrefix` is the agent +schema used. Relay any `warnings` (e.g. the target does not look like a cloned CLI agent, or the +schema prefix could not be read) - the files are still written, but a non-agent workspace will not +pick the skill up. + +For a `.zip` upload, unzip it to a folder first (so `--src` points at the extracted `SKILL.md` + files). -## 5. Report +## 6. Report Tell the user, concisely: - The skill name and slug. -- Where it was saved - the unpacked folder path, and the `.zip` path when one was produced. +- Where it was saved - the unpacked folder path (for a gallery skill this is the temporary + `/mcs-add-skill/` folder), and the `.zip` path when one was produced. - Whether it is a single-`SKILL.md` skill or a bundle with extra files. -- That **import into a Copilot Studio agent is not done yet** - it is a separate, upcoming step. +- If imported: the `behaviors//` path created, the `.mcs.yml` companions written (or that it + fell back to a bare skill, with the reason from `warnings`), and any other warnings; and that + **publishing to the cloud is the next step, done from the VS Code Copilot Studio extension** (Agent + Changes view / sync push) - this command does not push. ## Error handling diff --git a/scripts/add-skill.js b/scripts/add-skill.js index 5615d4f..dce235e 100644 --- a/scripts/add-skill.js +++ b/scripts/add-skill.js @@ -7,28 +7,44 @@ * be added to a Copilot Studio agent. * * Commands: - * list [--all] [--json] + * list [--all] [--pretty] [--json] * Enumerate gallery submissions and print the skills as JSON, sorted by * display name. By default only skills whose metadata `platforms` include - * "Copilot Studio" are returned; pass --all to include every skill. + * "Copilot Studio" are returned; pass --all to include every skill. Pass + * --pretty to render a plain, deterministic (fixed-width) box table instead + * of JSON. * - * download --slug --dest [--json] - * Download one skill into . The unpacked payload (SKILL.md plus any - * scripts/, references/, assets/) is written to //, and when the - * gallery publishes a prebuilt bundle for the skill, /.zip is - * saved too. + * download --slug [--dest ] [--json] + * Download one skill into . When --dest is omitted the skill is saved + * to a temporary user folder (/mcs-add-skill). The unpacked payload + * (SKILL.md plus any scripts/, references/, assets/) is written to + * //, and when the gallery publishes a prebuilt bundle for the + * skill, /.zip is saved too. + * + * import --src --workspace [--name ] [--force] + * [--include-sidecars] [--no-sidecars] [--json] + * Materialize a downloaded skill folder into a cloned Copilot Studio agent + * workspace as a skill under /behaviors//. The manifest is + * written as SKILL.md and every other payload file is copied verbatim + * (preserving scripts/ etc.). By default this also emits the portal-style + * .mcs.yml companions so the on-disk layout matches a Copilot Studio portal + * import: an anchor skill.mcs.yml (the InlineAgentSkill identity) plus one + * .mcs.yml sidecar per non-manifest payload file for bundle skills. + * The schema prefix for every component is read from the workspace + * settings.mcs.yml `schemaName`; when it cannot be determined (or with + * --no-sidecars) a bare behaviors/ skill is written instead and the VS Code + * Copilot Studio extension synthesizes the companions on the next read / pull. + * Pushing to the cloud is done from the extension afterwards. * * Zero runtime dependencies: uses the Node >=18 global fetch and the standard * library only. Optional GITHUB_TOKEN / GH_TOKEN raises the api.github.com rate * limit for the single tree call. - * - * NOTE: importing the downloaded skill into a Copilot Studio agent workspace - * (materializing it under behaviors/ and pushing with `pac copilot push`) is a - * separate, out-of-scope step and is intentionally not performed here. */ const fs = require('fs'); const path = require('path'); +const os = require('os'); +const crypto = require('crypto'); const REPO = 'microsoft/cat-agent-skills'; const BRANCH = 'main'; @@ -41,6 +57,28 @@ const SIDECARS = new Set(['metadata.json', 'metadata.yaml', 'metadata.yml', 'REA // Scaffolding folders in the gallery that are not real submissions. const TEMPLATES = new Set(['_template', '_template-automation']); +// The manifest the extension keys on to recognize a folder as a skill. It must +// be written with this exact name/casing (SkillLayout.ManifestFileName). +const MANIFEST_NAME = 'SKILL.md'; +// The folder skills live under in a cloned agent workspace (LspProjection.BehaviorsFolder). +const BEHAVIORS_DIR = 'behaviors'; +// The workspace layout marker + settings file a cloned CLI agent carries; bare +// skill folders are only synthesized in that layout (AgentClassifier.WorkspaceLayoutMarkerFileName). +const LAYOUT_MARKER = 'agent.sync.yaml'; +const SETTINGS_FILE = 'settings.mcs.yml'; +// Portal-style companions the extension normally synthesizes on pull; import can +// emit them directly so the on-disk layout matches a Copilot Studio portal import. +// The per-skill "anchor" carries the InlineAgentSkill identity; each bundle +// payload file gets a ".mcs.yml" sidecar next to it. +const SKILL_ANCHOR = 'skill.mcs.yml'; +const MCS_SIDECAR_SUFFIX = '.mcs.yml'; +// Windows reserved device names — unusable as a folder name there. +const RESERVED_DEVICE_NAMES = new Set([ + 'con', 'prn', 'aux', 'nul', + 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9', + 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9', +]); + function ghHeaders() { const headers = { 'User-Agent': 'mcs-assistant-add-skill', @@ -162,7 +200,15 @@ async function listSkills({ all } = {}) { skills = skills.filter((s) => s.platforms.some((p) => /copilot studio/i.test(p))); } - skills.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + // Deterministic ordering: case-insensitive by name using code-point order + // (locale-independent, unlike localeCompare) with slug as a stable tiebreaker, + // so the same gallery data always lists in exactly the same sequence. + skills.sort((a, b) => { + const an = a.name.toLowerCase(); + const bn = b.name.toLowerCase(); + if (an !== bn) return an < bn ? -1 : 1; + return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0; + }); return skills; } @@ -216,6 +262,323 @@ async function downloadSkill(slug, dest) { }; } +// Recursively list files under `dir`, returning POSIX-style paths relative to it. +function walkFiles(dir, base = dir) { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...walkFiles(abs, base)); + } else if (entry.isFile()) { + out.push(path.relative(base, abs).split(path.sep).join('/')); + } + } + return out; +} + +// Turn an arbitrary name into a single, filesystem- and schema-safe folder +// segment. The extension derives the skill's displayName and its +// `.skill.` schema name from this folder, so keep it to the +// Dataverse-friendly kebab set and well under the 100-char schema cap. +function sanitizeFolderName(name) { + let s = String(name || '') + .replace(/[\\/]+/g, '-') + .replace(/[^A-Za-z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^[-.]+|[-.]+$/g, ''); + if (s.length === 0) s = 'skill'; + if (RESERVED_DEVICE_NAMES.has(s.toLowerCase())) s = `skill-${s}`; + if (s.length > 60) s = s.slice(0, 60).replace(/[-.]+$/g, ''); + return s; +} + +function isManifestLeaf(rel) { + return rel.indexOf('/') < 0 && rel.toLowerCase() === MANIFEST_NAME.toLowerCase(); +} + +// --- Portal-style .mcs.yml companion emission ------------------------------- + +// Base62 uniqueness token that trails a Dataverse schema name +// (.._). Length is cosmetic — observed portal +// output uses ~3 chars for skills and ~5 for files — so we mirror that for +// parity; only validity and uniqueness actually matter. +const SCHEMA_TOKEN_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; +function schemaToken(len) { + const bytes = crypto.randomBytes(len); + let out = ''; + for (let i = 0; i < len; i++) out += SCHEMA_TOKEN_ALPHABET[bytes[i] % SCHEMA_TOKEN_ALPHABET.length]; + return out; +} + +// The of a file component's schema name: lowercase, alphanumerics only +// (SKILL.md -> skillmd, template.docx -> templatedocx, redline.py -> redlinepy). +function fileSchemaSegment(fileName) { + return String(fileName || '').toLowerCase().replace(/[^a-z0-9]+/g, ''); +} + +// The agent schemaName is the prefix every component hangs off; read it from the +// workspace settings file. Returns null when it cannot be determined. +function readAgentSchemaPrefix(wsDir) { + try { + const raw = fs.readFileSync(path.join(wsDir, SETTINGS_FILE), 'utf8'); + const m = raw.match(/^\uFEFF?schemaName:[ \t]*(\S+)[ \t]*$/m); + return m ? m[1] : null; + } catch { + return null; + } +} + +// Pull the `description` from the SKILL.md YAML frontmatter (single-line scalar). +// Returns '' when there is no frontmatter or no description key. +function readManifestDescription(manifestAbs) { + let raw; + try { + raw = fs.readFileSync(manifestAbs, 'utf8'); + } catch { + return ''; + } + raw = raw.replace(/^\uFEFF/, ''); + const fm = raw.match(/^---[^\n]*\n([\s\S]*?)\n---\s*(?:\n|$)/); + if (!fm) return ''; + const m = fm[1].match(/^description:[ \t]*(.*)$/m); + if (!m) return ''; + let val = m[1].trim(); + if (val.length >= 2 && val[0] === '"' && val.endsWith('"')) { + val = val.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + } else if (val.length >= 2 && val[0] === "'" && val.endsWith("'")) { + val = val.slice(1, -1).replace(/''/g, "'"); + } + return val; +} + +// Emit a YAML scalar the way the portal does: a plain scalar when unambiguous, +// otherwise a double-quoted scalar. Byte-compatible with portal-authored anchors +// for typical descriptions, while staying safe for values with YAML indicators. +function yamlScalar(value) { + const s = String(value == null ? '' : value); + const needsQuote = + s.length === 0 || + /^[\s\-?:,\[\]{}#&*!|>'"%@`]/.test(s) || + /:\s/.test(s) || + /\s#/.test(s) || + /:$/.test(s) || + /\s$/.test(s) || + /[\n\r\t]/.test(s); + if (!needsQuote) return s; + const escaped = s + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); + return `"${escaped}"`; +} + +// Write the portal-style companions for an already-materialized behaviors// +// skill: the anchor skill.mcs.yml, plus one .mcs.yml sidecar per non-manifest +// payload file (bundle skills only). `payloadRel` are POSIX-relative paths under the +// skill folder, excluding the manifest. Returns the list of companion paths written +// (relative to the workspace, POSIX-style). +function writeMcsCompanions({ behaviorsDir, folder, prefix, description, payloadRel }) { + const isBundle = payloadRel.length > 0; + const written = []; + + const anchorLines = ['mcs.metadata:', ` componentName: ${folder}`]; + if (description) anchorLines.push(` description: ${yamlScalar(description)}`); + anchorLines.push(` schemaName: ${prefix}.skill.${folder}_${schemaToken(3)}`); + if (isBundle) { + anchorLines.push(` bundle: ${prefix}.file.${fileSchemaSegment(`${folder}.zip`)}_${schemaToken(5)}`); + anchorLines.push(` manifestSchemaName: ${prefix}.file.${fileSchemaSegment(MANIFEST_NAME)}_${schemaToken(5)}`); + } + anchorLines.push('kind: InlineAgentSkill'); + anchorLines.push('authoringSource: Upload'); + // The portal writes the anchor without a trailing newline; sidecars keep theirs. + fs.writeFileSync(path.join(behaviorsDir, SKILL_ANCHOR), anchorLines.join('\n')); + written.push(`${BEHAVIORS_DIR}/${folder}/${SKILL_ANCHOR}`); + + if (isBundle) { + for (const rel of payloadRel) { + const leaf = rel.split('/').pop(); + const lines = [ + 'mcs.metadata:', + ` componentName: ./${rel}`, + ` schemaName: ${prefix}.file.${fileSchemaSegment(leaf)}_${schemaToken(5)}`, + ]; + fs.writeFileSync(path.join(behaviorsDir, rel) + MCS_SIDECAR_SUFFIX, lines.join('\n') + '\n'); + written.push(`${BEHAVIORS_DIR}/${folder}/${rel}${MCS_SIDECAR_SUFFIX}`); + } + } + return written; +} + +// Materialize a downloaded skill folder as a bare behaviors// skill in a +// cloned agent workspace. Writes SKILL.md + every payload file verbatim; the +// extension synthesizes the InlineAgentSkill component (and parents every file +// to it) on the next workspace read / sync. +function importSkill({ src, workspace, name, force, includeSidecars, noSidecars }) { + const srcDir = path.resolve(src); + if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) { + throw new Error(`--src is not a directory: ${srcDir}`); + } + const wsDir = path.resolve(workspace); + if (!fs.existsSync(wsDir) || !fs.statSync(wsDir).isDirectory()) { + throw new Error(`--workspace is not a directory: ${wsDir}`); + } + + const allFiles = walkFiles(srcDir); + const manifestRel = allFiles.find(isManifestLeaf); + if (!manifestRel) { + throw new Error(`No ${MANIFEST_NAME} found at the top of ${srcDir}; this folder is not an importable skill.`); + } + + // Non-fatal environment checks — surface them so the user knows why a synced + // skill might not appear if the target is not a cloned CLI agent. + const warnings = []; + if (!fs.existsSync(path.join(wsDir, SETTINGS_FILE))) { + warnings.push(`Workspace has no ${SETTINGS_FILE}; it does not look like a cloned agent. Clone/attach an agent here first, or the skill will not be picked up.`); + } + if (!fs.existsSync(path.join(wsDir, LAYOUT_MARKER))) { + warnings.push(`Workspace has no ${LAYOUT_MARKER} layout marker; bare ${BEHAVIORS_DIR}/ skills are only synthesized in code-first (CLI) agent workspaces.`); + } else { + try { + const settings = fs.readFileSync(path.join(wsDir, SETTINGS_FILE), 'utf8'); + if (!/CLICopilotRecognizer|cliagent/i.test(settings)) { + warnings.push(`${SETTINGS_FILE} does not look like a code-first (CLI) agent; InlineAgentSkill is only supported on Copilot Studio CLI agents.`); + } + } catch { + /* best-effort */ + } + } + + const folder = sanitizeFolderName(name || path.basename(srcDir)); + const behaviorsDir = path.join(wsDir, BEHAVIORS_DIR, folder); + if (fs.existsSync(behaviorsDir)) { + if (!force) { + throw new Error(`${BEHAVIORS_DIR}/${folder} already exists in the workspace. Pass --force to overwrite, or choose another --name.`); + } + fs.rmSync(behaviorsDir, { recursive: true, force: true }); + } + + const written = []; + let extraManifestSkipped = false; + for (const rel of allFiles) { + const leaf = rel.split('/').pop(); + if (!includeSidecars && rel.indexOf('/') < 0 && SIDECARS.has(leaf)) continue; + if (leaf.toLowerCase().endsWith('.zip')) continue; + // MCS companions are (re)generated below, never copied from the source. + if (leaf.toLowerCase().endsWith(MCS_SIDECAR_SUFFIX)) continue; + // The manifest must land as exactly SKILL.md; skip any duplicate-cased root manifest. + let destRel = rel; + if (isManifestLeaf(rel)) { + if (rel === manifestRel) destRel = MANIFEST_NAME; + else { extraManifestSkipped = true; continue; } + } + const target = path.join(behaviorsDir, destRel); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(path.join(srcDir, rel), target); + written.push(`${BEHAVIORS_DIR}/${folder}/${destRel.split(path.sep).join('/')}`); + } + if (extraManifestSkipped) { + warnings.push(`Multiple root manifest files found; kept "${manifestRel}" as ${MANIFEST_NAME} and skipped the other.`); + } + + // Emit portal-style .mcs.yml companions so the on-disk layout matches a Copilot + // Studio portal import. Requires the agent schema prefix from settings.mcs.yml; + // without it (or with --no-sidecars) we leave a bare behaviors/ skill for the + // extension to synthesize on pull. + let companions = []; + let schemaPrefix = null; + if (!noSidecars) { + schemaPrefix = readAgentSchemaPrefix(wsDir); + if (!schemaPrefix) { + warnings.push(`Could not read the agent schemaName from ${SETTINGS_FILE}; wrote a bare ${BEHAVIORS_DIR}/ skill without .mcs.yml companions (the extension will synthesize them on pull).`); + } else { + const prefixLen = `${BEHAVIORS_DIR}/${folder}/`.length; + const payloadRel = written + .map((w) => w.slice(prefixLen)) + .filter((rel) => rel.toLowerCase() !== MANIFEST_NAME.toLowerCase()); + const description = readManifestDescription(path.join(behaviorsDir, MANIFEST_NAME)); + companions = writeMcsCompanions({ behaviorsDir, folder, prefix: schemaPrefix, description, payloadRel }); + } + } + + return { + folder, + workspace: wsDir, + behaviorsDir, + manifest: `${BEHAVIORS_DIR}/${folder}/${MANIFEST_NAME}`, + files: written.sort(), + companions: companions.sort(), + schemaPrefix, + warnings, + next: [ + `Open the agent workspace in VS Code (Copilot Studio extension) to see "${folder}" under Skills.`, + 'Use the Agent Changes view (or sync push) to publish the new skill to the cloud.', + ], + }; +} + +// --- Pretty gallery rendering ---------------------------------------------- +// `list --pretty` prints a plain box table instead of JSON so the gallery is +// easy to browse in the terminal. Output is deterministic (fixed width) and +// includes a `Slug` column so the exact download slug sits next to each row. + +// Fixed render width so the table is byte-for-byte identical every run, +// independent of the terminal size. Column widths derive only from this and the +// (deterministic) gallery data, never from process.stdout.columns. +const PRETTY_WIDTH = 120; + +// Collapse whitespace, truncate with an ellipsis, and pad to an exact width. +function fitCell(text, width, align) { + let s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim(); + if (s.length > width) s = width <= 1 ? '…' : s.slice(0, width - 1).replace(/\s+$/, '') + '…'; + const pad = ' '.repeat(Math.max(0, width - s.length)); + return align === 'right' ? pad + s : s + pad; +} + +function renderPrettyList(skills, { width = PRETTY_WIDTH } = {}) { + const term = Math.max(72, Math.min(width, 200)); + const wNum = Math.max(2, String(skills.length).length); + const wBundle = 1; + const wName = 28; + // Slugs are the exact key used for `download --slug`, so never truncate them: + // size the column to the longest slug (bounded) and let Description flex. + const maxSlug = skills.reduce((m, s) => Math.max(m, String(s.slug || '').length), 0); + const wSlug = Math.min(42, Math.max(8, maxSlug)); + // Each column renders as "│ " (1 border + 2 spaces = 3 chars) and the + // row ends with a trailing "│": 5 columns => 5*3 + 1 = 16 chars of chrome. + const chrome = 5 * 3 + 1; + const wDesc = Math.max(14, term - (wNum + wBundle + wName + wSlug + chrome)); + + const widths = [wNum, wBundle, wName, wDesc, wSlug]; + const rule = (l, m, r) => l + widths.map((w) => '─'.repeat(w + 2)).join(m) + r; + const cell = (text, w, align) => ` ${fitCell(text, w, align)} `; + + const out = []; + out.push(rule('┌', '┬', '┐')); + out.push( + '│' + cell('#', wNum, 'right') + + '│' + cell('◆', wBundle, 'left') + + '│' + cell('Name', wName, 'left') + + '│' + cell('Description', wDesc, 'left') + + '│' + cell('Slug', wSlug, 'left') + '│'); + out.push(rule('├', '┼', '┤')); + skills.forEach((s, i) => { + out.push( + '│' + cell(String(i + 1), wNum, 'right') + + '│' + cell(s.hasBundle ? '◆' : ' ', wBundle, 'left') + + '│' + cell(s.name || s.slug, wName, 'left') + + '│' + cell(s.description, wDesc, 'left') + + '│' + cell(s.slug, wSlug, 'left') + '│'); + }); + out.push(rule('└', '┴', '┘')); + + const heading = `Cat Agent Skills ${skills.length} skills for Copilot Studio`; + const legend = '◆ = bundle (ships scripts / references / assets) · others are a single SKILL.md'; + + return `\n${heading}\n\n${out.join('\n')}\n\n${legend}`; +} + function parseArgs(argv) { const args = { _: [] }; for (let i = 0; i < argv.length; i++) { @@ -242,19 +605,41 @@ async function main() { if (cmd === 'list') { const skills = await listSkills({ all: !!args.all }); + if (args.pretty) { + process.stdout.write(renderPrettyList(skills) + '\n'); + return; + } process.stdout.write(JSON.stringify({ ok: true, count: skills.length, skills }, null, 2) + '\n'); return; } if (cmd === 'download') { if (!args.slug) throw new Error('--slug is required for download.'); - const dest = path.resolve(typeof args.dest === 'string' ? args.dest : process.cwd()); + // Default to a temporary user folder so gallery downloads don't clutter the + // working directory; the returned `dir` is used as --src for import. + const dest = path.resolve( + typeof args.dest === 'string' ? args.dest : path.join(os.tmpdir(), 'mcs-add-skill')); const result = await downloadSkill(args.slug, dest); process.stdout.write(JSON.stringify({ ok: true, ...result }, null, 2) + '\n'); return; } - throw new Error(`Unknown command: ${cmd || '(none)'}. Use "list" or "download".`); + if (cmd === 'import') { + if (typeof args.src !== 'string') throw new Error('--src is required for import.'); + if (typeof args.workspace !== 'string') throw new Error('--workspace is required for import.'); + const result = importSkill({ + src: args.src, + workspace: args.workspace, + name: typeof args.name === 'string' ? args.name : undefined, + force: !!args.force, + includeSidecars: !!args['include-sidecars'], + noSidecars: !!args['no-sidecars'], + }); + process.stdout.write(JSON.stringify({ ok: true, ...result }, null, 2) + '\n'); + return; + } + + throw new Error(`Unknown command: ${cmd || '(none)'}. Use "list", "download" or "import".`); } main().catch((err) => { From 56c0cefe1643bb48bae86951a79494a09e12f727 Mon Sep 17 00:00:00 2001 From: anderson-joyle Date: Thu, 17 Sep 2026 12:44:58 -0500 Subject: [PATCH 3/4] Centralize the skill schema into reference/skill-schema.md The skill schema was duplicated across three places that could drift: the copilot-studio-architect agent (inline variant), the /add-skill command (upload variant), and scripts/add-skill.js (the generator). Mirror what reference/knowledge-schema.md already does for knowledge sources and make a single file the source of truth. The two consumers now resolve the reference via the plugin root and follow it, keeping classification heuristics (skill vs tool, skill vs knowledge) in the architect where they belong. The inline-vs-upload selection rule is recorded as an open question rather than guessed at. Writing it down exposed three gaps in the generator, now fixed and covered by tests: - componentName bypassed yamlScalar on both the anchor and the sidecars, so a folder named "on" or "123" came back as a boolean or a number, and a payload path containing " #" was truncated at the YAML comment marker. yamlScalar also now quotes plain scalars that resolve to non-strings under YAML 1.1. - Component schema names had no 100-character Dataverse budget. Segments are truncated with a warning, and an agent schema name that leaves no room fails before any file is written instead of half-importing a skill. - main() ran on require, so the module could not be tested. Guard it with require.main and export the tested surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f42cd36d-11db-48e7-a770-959e3d0e40d7 --- agents/copilot-studio-architect.md | 26 ++-- commands/add-skill.md | 28 ++-- reference/skill-schema.md | 210 +++++++++++++++++++++++++++++ scripts/add-skill.js | 114 +++++++++++++--- scripts/test/add-skill.test.js | 121 +++++++++++++++++ 5 files changed, 460 insertions(+), 39 deletions(-) create mode 100644 reference/skill-schema.md create mode 100644 scripts/test/add-skill.test.js diff --git a/agents/copilot-studio-architect.md b/agents/copilot-studio-architect.md index 022f7dc..55f52c0 100644 --- a/agents/copilot-studio-architect.md +++ b/agents/copilot-studio-architect.md @@ -195,21 +195,19 @@ Only create or substantially modify tool YAML when the describer report or migra ## Skill YAML -Create focused inline skills under `behaviors\` for reusable multi-step procedures: +Skill components live in `behaviors\`. Create focused skills there for reusable multi-step +procedures. + +The **authoritative skill schema** — the inline and upload variants, the exact YAML shapes and +fields, the `behaviors/` file layout, anchor/sidecar rules, folder naming, and schema-name +conventions — lives in a single shared reference, `reference/skill-schema.md`. Read it and follow it +exactly; the `/add-skill` command and its importer use the same file, so the three never drift. +Resolve its path via the plugin root: read +`path.join(os.homedir(), '.copilot-studio-cli', 'plugin-paths.json')` to get `pluginRoot` for the +current `mcs-assistant` plugin, then read `path.join(pluginRoot, 'reference', 'skill-schema.md')`. -```yaml -mcs.metadata: - componentName: make-restaurant-reservation - description: Guides the user through making a restaurant reservation. -kind: InlineAgentSkill -content: | - --- - name: make-restaurant-reservation - description: Guides the user through making a restaurant reservation. - --- - - -``` +Author new skills as the **inline** variant (`kind: InlineAgentSkill` with a `content:` block), which +is what that reference documents for this agent. Skill content should include trigger/use guidance, required inputs, clarifying questions, tool-use steps, confirmation rules for side effects, expected outputs, and fallback/escalation behavior. Prefer a few focused skills over one large skill. Do not create speculative skills that duplicate global instructions or knowledge retrieval. diff --git a/commands/add-skill.md b/commands/add-skill.md index b4131ec..070cb41 100644 --- a/commands/add-skill.md +++ b/commands/add-skill.md @@ -21,6 +21,20 @@ command never pushes. Initial request: $ARGUMENTS +## Authoritative schema — read this before importing + +The exact YAML for every skill component, the `behaviors/` file layout, anchor/sidecar rules, folder +naming, and schema-name conventions live in a single shared reference — +**`reference/skill-schema.md`**. It is the source of truth; the `copilot-studio-architect` agent and +`scripts/add-skill.js` use the same file, so the three never drift. **Read it before step 5** and +follow it exactly. + +Resolve its path via the plugin root: read +`path.join(os.homedir(), '.copilot-studio-cli', 'plugin-paths.json')` to get `pluginRoot` for the +current `mcs-assistant` plugin, then read `path.join(pluginRoot, 'reference', 'skill-schema.md')`. +If `plugin-paths.json` cannot be read, fall back to locating `reference/skill-schema.md` under the +installed plugin directory. + --- ## 1. Locate the helper script (non-blocking) @@ -105,18 +119,16 @@ the user does not have one yet, tell them to clone/attach an agent first, and st Import writes the skill under `behaviors//`: the manifest as `SKILL.md` and every other payload file (e.g. `scripts/`) copied verbatim. By default it then also emits the **portal-style `.mcs.yml` -companions** so the layout matches a Copilot Studio portal import: - -- an anchor `skill.mcs.yml` carrying the `InlineAgentSkill` identity (`componentName`, the manifest - `description`, a `schemaName`, `kind`, `authoringSource: Upload`), and -- for **bundle** skills (any payload file beyond `SKILL.md`), a `bundle` + `manifestSchemaName` on the - anchor plus one `.mcs.yml` sidecar next to every non-manifest payload file. Single-`SKILL.md` - skills get only the anchor (no `bundle`, no sidecars). +companions** — an anchor `skill.mcs.yml` plus, for bundle skills, one sidecar per non-manifest +payload file — so the on-disk layout matches a Copilot Studio portal import. The script owns those +shapes; `reference/skill-schema.md` documents them. Every component's schema name is prefixed with the agent `schemaName` read from the workspace `settings.mcs.yml`. If that prefix cannot be determined - or you pass `--no-sidecars` - the import falls back to a **bare `behaviors//` folder** and the extension synthesizes the companions on -the next workspace read / sync (a `warning` explains which happened). +the next workspace read / sync (a `warning` explains which happened). If the agent `schemaName` is so +long that it leaves no room for a component name under the 100-character Dataverse limit, the import +fails before writing anything - relay that error and suggest a shorter agent schema name. ```bash node "" import --src "" --workspace "" [--name ""] [--force] [--no-sidecars] diff --git a/reference/skill-schema.md b/reference/skill-schema.md new file mode 100644 index 0000000..157e4f0 --- /dev/null +++ b/reference/skill-schema.md @@ -0,0 +1,210 @@ +# Agent Skill Schema (authoritative) + +**Single source of truth** for how agent skills are represented in a modern Copilot Studio +**agentic-loop** agent (`behaviors/`). The `/add-skill` command, the `scripts/add-skill.js` importer, +and the `copilot-studio-architect` agent all consult this file — edit the schema **here only** so the +three never drift. + +The shapes below match what the platform produces when a skill is added in the browser and cloned +locally with `pac copilot`. + +--- + +## Two variants + +A skill is always `kind: InlineAgentSkill`, but it is materialized in one of two ways: + +| Variant | Where the skill text lives | Marker | Emitted by | +|---|---|---|---| +| **Inline** | Embedded in the component's `content:` block | no `authoringSource` | `copilot-studio-architect` when authoring a new skill from an idea | +| **Upload** | A real `SKILL.md` file on disk, plus optional payload files | `authoringSource: Upload` | `/add-skill` import (`scripts/add-skill.js`), and a portal upload | + +> **Open question — variant selection.** The platform accepts both, but there is currently no +> documented rule for *when* an author should prefer one over the other, and no verified statement +> that they are functionally equivalent at runtime. Until that is confirmed, keep using the variant +> each producer already emits (architect → inline; `/add-skill` → upload) and do not convert between +> them. Resolve this before relying on cross-variant behavior. + +## File layout + +```text +/ +└── behaviors/ + └── / + ├── SKILL.md # upload variant only — the manifest, this exact casing + ├── skill.mcs.yml # upload variant only — the anchor component + ├── scripts/ + │ ├── redline.py # payload file, copied verbatim + │ └── redline.py.mcs.yml # one sidecar per non-manifest payload file + └── .mcs.yml # inline variant — the whole skill in one component +``` + +A **single-`SKILL.md`** skill gets only the anchor: no `bundle`, no `manifestSchemaName`, no sidecars. +A **bundle** skill (any payload file beyond `SKILL.md`) gets all three. + +## Inline variant — `InlineAgentSkill` with `content:` + +```yaml +mcs.metadata: + componentName: make-restaurant-reservation + description: "Guides the user through making a restaurant reservation." +kind: InlineAgentSkill +content: | + --- + name: make-restaurant-reservation + description: Guides the user through making a restaurant reservation. + --- + + +``` + +- `componentName` — the skill's kebab-case identifier. +- `description` — what the skill *does*, in enough detail that the orchestrator can decide when to + invoke it. This is the selection signal; a vague description means the skill never fires. +- `content:` — a literal block scalar holding the full manifest, **including its own YAML + frontmatter**. The `name`/`description` inside the frontmatter should match `mcs.metadata`. +- `` — the provenance marker the platform writes for a skill authored from + scratch. Preserve it verbatim on edit. + +## Upload variant — anchor + sidecars + +### Anchor — `behaviors//skill.mcs.yml` + +```yaml +mcs.metadata: + componentName: pdf-redline + description: Redlines a PDF contract and produces a summary of changes. + schemaName: crbab_guitarcoach_dcF_b3.skill.pdf-redline_Kq2 + bundle: crbab_guitarcoach_dcF_b3.file.pdfredlinezip_9Xa2L + manifestSchemaName: crbab_guitarcoach_dcF_b3.file.skillmd_bT4nQ +kind: InlineAgentSkill +authoringSource: Upload +``` + +- `componentName` — the `behaviors/` name. +- `description` — read from the `description` key of the `SKILL.md` YAML frontmatter. Omit the line + entirely when the manifest has none. +- `schemaName` — see [Schema names](#schema-names). +- `bundle` / `manifestSchemaName` — **bundle skills only**. `bundle` names the notional + `.zip` file component; `manifestSchemaName` names the `SKILL.md` file component. +- `authoringSource: Upload` — required; it is what distinguishes this variant. +- The portal writes the anchor **without a trailing newline**; sidecars keep theirs. Match that for + byte-parity. + +### Payload sidecar — `behaviors//.mcs.yml` + +One per non-manifest payload file, written *next to* the file it describes (so `scripts/redline.py` +gets `scripts/redline.py.mcs.yml`). Metadata only — no `kind:`, no `authoringSource:`. + +```yaml +mcs.metadata: + componentName: ./scripts/redline.py + schemaName: crbab_guitarcoach_dcF_b3.file.redlinepy_9Xa2L +``` + +- `componentName` — the POSIX-style path **relative to the skill folder**, prefixed with `./`. +- No sidecar is written for `SKILL.md` itself; it is named by the anchor's `manifestSchemaName`. + +### Bare fallback + +When the agent `schemaName` cannot be read from `settings.mcs.yml`, write the payload files only +(no anchor, no sidecars) and warn. The VS Code Copilot Studio extension synthesizes the companions on +the next workspace read / sync. A bare skill is only recognized inside a **code-first (CLI) agent** +workspace — one carrying `agent.sync.yaml` and a `settings.mcs.yml` that names a CLI recognizer. + +## YAML-safe scalar encoding + +Treat folder names, descriptions, and payload paths as external data, not YAML. Never paste a value +directly after `componentName:` or `description:`. + +A value **must** be emitted as a double-quoted scalar whenever it: + +- is empty, starts with a YAML indicator (`- ? : , [ ] { } # & * ! | > ' " % @ \``) or whitespace, + contains `: `, ` #`, a trailing `:`, trailing whitespace, or any newline/tab; **or** +- would resolve to a non-string under **YAML 1.1**, which is what the Copilot Studio tooling reads + these files with — `y`, `n`, `yes`, `no`, `true`, `false`, `on`, `off` (any casing); `~`, `null`; + any integer, float, hex, octal, `.inf`/`.nan`; and sexagesimals such as `1:30`. + +A skill folder named `on` or `123` is the common trap: emitted plain, it comes back as the boolean +`true` or the number `123`. + +Per variant: + +- **Upload** — quote only when the rules above require it. The portal writes plain scalars for + ordinary values, and the anchor should stay byte-comparable with a portal import. The reference + implementation is `yamlScalar()` in `scripts/add-skill.js`; it already enforces this, so do not + hand-edit generated companions. +- **Inline** — always double-quote `componentName` and `description`. These are authored by a model + from user text, where the cheapest safe rule is to quote unconditionally. + +## Folder naming + +The `behaviors/` segment is also the skill's display name and the source of its schema +segment, so keep it filesystem- and Dataverse-safe: + +- Replace path separators with `-`, reduce every other character outside `[A-Za-z0-9._-]` to `-`, + collapse runs of `-`, and trim leading/trailing `-` and `.`. +- Fall back to `skill` when that leaves an empty string. +- Prefix Windows reserved device names (`con`, `prn`, `aux`, `nul`, `com1`–`com9`, `lpt1`–`lpt9`) + with `skill-`. +- Cap the result at 60 characters. This is a readability cap, **not** the schema limit — see below. + +## Schema names + +Every component schema name has the shape: + +```text +.._ +``` + +| Component | `` | `` | `` | +|---|---|---|---| +| Skill anchor | `skill` | the `behaviors/` folder name | 3 chars | +| Manifest, bundle, payload file | `file` | the file name, lowercased, non-alphanumerics removed (`SKILL.md` → `skillmd`, `redline.py` → `redlinepy`) | 5 chars | + +`` is read from the workspace `settings.mcs.yml` (e.g. `crbab_guitarcoach_dcF_b3`). +`` is a random base62 string; its length is cosmetic parity with portal output — only validity +and uniqueness matter. + +### 100-character schema-name budget + +Dataverse caps a schema name at 100 characters. Apply the limit **before** writing: + +- Segment budget = `100 - length(agentSchemaName) - length(infix) - length(token) - 3` + (the two dots and the underscore). +- Strip unsupported characters first, then truncate the segment on the right to its budget, and warn + that the on-disk name and the schema name now differ. +- If the budget is less than one character for **either** shape, stop and report that the agent + schema name leaves no valid component-name space. Check this before materializing any file, so a + failure does not leave a half-written skill folder behind. + +The 60-character folder cap is not sufficient on its own: a 40-character agent schema name plus a +60-character folder yields a 111-character anchor schema name. + +## Collision and overwrite rules + +- Never overwrite an existing `behaviors//` without explicit confirmation (`--force`). +- Regenerate companions; never copy a `.mcs.yml` from the skill source. +- Gallery sidecars (`metadata.json`, `metadata.yaml`, `metadata.yml`, `README.md`) and any `.zip` are + excluded from the payload by default. +- The manifest must land as exactly `SKILL.md`. If the source has several root manifests differing + only by casing, keep one and report the one skipped. + +## Best practices + +- **Prefer a few focused skills over one comprehensive package.** Each skill should cover one clear + procedure. +- **Add a skill only when the task genuinely needs a procedure.** For tasks a model already handles + from general knowledge, a skill adds little and can get in the way. +- **Write the description for the orchestrator.** It is the selection signal, not documentation. +- Skill content should cover trigger/use guidance, required inputs, clarifying questions, tool-use + steps, confirmation rules for side effects, expected outputs, and fallback/escalation behavior. + +Classification guidance — skill vs tool, skill vs knowledge, and skill effectiveness heuristics — +lives in the `copilot-studio-architect` agent, not here. This file covers the **schema** only. + +## Limitations + +- `InlineAgentSkill` is supported on **Copilot Studio CLI (code-first) agents** only. +- Import materializes files on disk. **Publishing is done from the VS Code Copilot Studio extension** + (Agent Changes view / sync push); no command here pushes to the cloud. diff --git a/scripts/add-skill.js b/scripts/add-skill.js index dce235e..67be123 100644 --- a/scripts/add-skill.js +++ b/scripts/add-skill.js @@ -39,6 +39,10 @@ * Zero runtime dependencies: uses the Node >=18 global fetch and the standard * library only. Optional GITHUB_TOKEN / GH_TOKEN raises the api.github.com rate * limit for the single tree call. + * + * The component shapes this script emits are documented in + * reference/skill-schema.md. Keep the two in sync: that file is what the + * /add-skill command and the copilot-studio-architect agent read. */ const fs = require('fs'); @@ -72,6 +76,8 @@ const SETTINGS_FILE = 'settings.mcs.yml'; // payload file gets a ".mcs.yml" sidecar next to it. const SKILL_ANCHOR = 'skill.mcs.yml'; const MCS_SIDECAR_SUFFIX = '.mcs.yml'; +// Dataverse caps a schema name at 100 characters. See reference/skill-schema.md. +const SCHEMA_NAME_MAX = 100; // Windows reserved device names — unusable as a folder name there. const RESERVED_DEVICE_NAMES = new Set([ 'con', 'prn', 'aux', 'nul', @@ -316,6 +322,41 @@ function fileSchemaSegment(fileName) { return String(fileName || '').toLowerCase().replace(/[^a-z0-9]+/g, ''); } +// A component schema name is `.._`, so the segment +// gets whatever the 100-char Dataverse cap leaves after the prefix, the infix, the +// token, and the 3 separators (two dots, one underscore). +function schemaSegmentBudget(prefix, infix, tokenLen) { + return SCHEMA_NAME_MAX - String(prefix || '').length - infix.length - tokenLen - 3; +} + +// The two component shapes a skill import emits. A prefix that leaves no room for +// either one is fatal — better to stop than to write a schema name Dataverse rejects. +const SCHEMA_SHAPES = [ + { infix: 'skill', tokenLen: 3, label: 'the skill anchor' }, + { infix: 'file', tokenLen: 5, label: 'skill payload files' }, +]; + +function assertSchemaBudget(prefix) { + for (const { infix, tokenLen, label } of SCHEMA_SHAPES) { + if (schemaSegmentBudget(prefix, infix, tokenLen) < 1) { + throw new Error( + `The agent schema name "${prefix}" leaves no valid component-name space for ${label} under the ${SCHEMA_NAME_MAX}-character Dataverse limit.`); + } + } +} + +// Truncate a schema segment on the right to its budget, warning when it happens so +// the caller can tell the user the on-disk name and the schema name diverged. +function fitSchemaSegment(segment, { prefix, infix, tokenLen, label, warnings }) { + const budget = schemaSegmentBudget(prefix, infix, tokenLen); + const seg = String(segment || ''); + if (seg.length <= budget) return seg; + const cut = seg.slice(0, budget); + warnings.push( + `Schema name for ${label} was truncated to fit the ${SCHEMA_NAME_MAX}-character Dataverse limit ("${seg}" -> "${cut}").`); + return cut; +} + // The agent schemaName is the prefix every component hangs off; read it from the // workspace settings file. Returns null when it cannot be determined. function readAgentSchemaPrefix(wsDir) { @@ -351,6 +392,16 @@ function readManifestDescription(manifestAbs) { return val; } +// Plain scalars a YAML 1.1 parser (what the Copilot Studio tooling reads these +// files with) resolves to something other than a string. Names like "on", "123", +// or "null" must be quoted or they come back as a boolean, a number, or null. +const YAML11_BOOL = /^(y|n|yes|no|true|false|on|off)$/i; +const YAML11_NULL = /^(~|null)$/i; +const YAML11_NUMBER = + /^[-+]?(0x[0-9a-f_]+|0o?[0-7_]+|[0-9][0-9_]*(\.[0-9_]*)?([eE][-+]?[0-9]+)?|\.[0-9_]+([eE][-+]?[0-9]+)?|\.(inf|nan))$/i; +// Sexagesimals ("1:30") are integers in YAML 1.1. +const YAML11_SEXAGESIMAL = /^[-+]?[0-9][0-9_]*(:[0-5]?[0-9])+(\.[0-9_]*)?$/; + // Emit a YAML scalar the way the portal does: a plain scalar when unambiguous, // otherwise a double-quoted scalar. Byte-compatible with portal-authored anchors // for typical descriptions, while staying safe for values with YAML indicators. @@ -363,7 +414,11 @@ function yamlScalar(value) { /\s#/.test(s) || /:$/.test(s) || /\s$/.test(s) || - /[\n\r\t]/.test(s); + /[\n\r\t]/.test(s) || + YAML11_BOOL.test(s) || + YAML11_NULL.test(s) || + YAML11_NUMBER.test(s) || + YAML11_SEXAGESIMAL.test(s); if (!needsQuote) return s; const escaped = s .replace(/\\/g, '\\\\') @@ -379,16 +434,25 @@ function yamlScalar(value) { // payload file (bundle skills only). `payloadRel` are POSIX-relative paths under the // skill folder, excluding the manifest. Returns the list of companion paths written // (relative to the workspace, POSIX-style). -function writeMcsCompanions({ behaviorsDir, folder, prefix, description, payloadRel }) { +function writeMcsCompanions({ behaviorsDir, folder, prefix, description, payloadRel, warnings }) { const isBundle = payloadRel.length > 0; const written = []; - const anchorLines = ['mcs.metadata:', ` componentName: ${folder}`]; + const anchorLines = ['mcs.metadata:', ` componentName: ${yamlScalar(folder)}`]; if (description) anchorLines.push(` description: ${yamlScalar(description)}`); - anchorLines.push(` schemaName: ${prefix}.skill.${folder}_${schemaToken(3)}`); + const skillSeg = fitSchemaSegment(folder, { + prefix, infix: 'skill', tokenLen: 3, label: `the skill anchor "${folder}"`, warnings, + }); + anchorLines.push(` schemaName: ${prefix}.skill.${skillSeg}_${schemaToken(3)}`); if (isBundle) { - anchorLines.push(` bundle: ${prefix}.file.${fileSchemaSegment(`${folder}.zip`)}_${schemaToken(5)}`); - anchorLines.push(` manifestSchemaName: ${prefix}.file.${fileSchemaSegment(MANIFEST_NAME)}_${schemaToken(5)}`); + const bundleSeg = fitSchemaSegment(fileSchemaSegment(`${folder}.zip`), { + prefix, infix: 'file', tokenLen: 5, label: `the "${folder}" bundle`, warnings, + }); + const manifestSeg = fitSchemaSegment(fileSchemaSegment(MANIFEST_NAME), { + prefix, infix: 'file', tokenLen: 5, label: `the ${MANIFEST_NAME} manifest`, warnings, + }); + anchorLines.push(` bundle: ${prefix}.file.${bundleSeg}_${schemaToken(5)}`); + anchorLines.push(` manifestSchemaName: ${prefix}.file.${manifestSeg}_${schemaToken(5)}`); } anchorLines.push('kind: InlineAgentSkill'); anchorLines.push('authoringSource: Upload'); @@ -399,10 +463,13 @@ function writeMcsCompanions({ behaviorsDir, folder, prefix, description, payload if (isBundle) { for (const rel of payloadRel) { const leaf = rel.split('/').pop(); + const fileSeg = fitSchemaSegment(fileSchemaSegment(leaf), { + prefix, infix: 'file', tokenLen: 5, label: `payload file "${rel}"`, warnings, + }); const lines = [ 'mcs.metadata:', - ` componentName: ./${rel}`, - ` schemaName: ${prefix}.file.${fileSchemaSegment(leaf)}_${schemaToken(5)}`, + ` componentName: ${yamlScalar(`./${rel}`)}`, + ` schemaName: ${prefix}.file.${fileSeg}_${schemaToken(5)}`, ]; fs.writeFileSync(path.join(behaviorsDir, rel) + MCS_SIDECAR_SUFFIX, lines.join('\n') + '\n'); written.push(`${BEHAVIORS_DIR}/${folder}/${rel}${MCS_SIDECAR_SUFFIX}`); @@ -452,6 +519,13 @@ function importSkill({ src, workspace, name, force, includeSidecars, noSidecars const folder = sanitizeFolderName(name || path.basename(srcDir)); const behaviorsDir = path.join(wsDir, BEHAVIORS_DIR, folder); + + // The agent schema prefix decides how much room a component name has under the + // Dataverse cap. Resolve and validate it before materializing anything, so an + // unusable prefix fails fast instead of leaving a half-written skill behind. + const schemaPrefix = noSidecars ? null : readAgentSchemaPrefix(wsDir); + if (schemaPrefix) assertSchemaBudget(schemaPrefix); + if (fs.existsSync(behaviorsDir)) { if (!force) { throw new Error(`${BEHAVIORS_DIR}/${folder} already exists in the workspace. Pass --force to overwrite, or choose another --name.`); @@ -487,9 +561,7 @@ function importSkill({ src, workspace, name, force, includeSidecars, noSidecars // without it (or with --no-sidecars) we leave a bare behaviors/ skill for the // extension to synthesize on pull. let companions = []; - let schemaPrefix = null; if (!noSidecars) { - schemaPrefix = readAgentSchemaPrefix(wsDir); if (!schemaPrefix) { warnings.push(`Could not read the agent schemaName from ${SETTINGS_FILE}; wrote a bare ${BEHAVIORS_DIR}/ skill without .mcs.yml companions (the extension will synthesize them on pull).`); } else { @@ -498,7 +570,7 @@ function importSkill({ src, workspace, name, force, includeSidecars, noSidecars .map((w) => w.slice(prefixLen)) .filter((rel) => rel.toLowerCase() !== MANIFEST_NAME.toLowerCase()); const description = readManifestDescription(path.join(behaviorsDir, MANIFEST_NAME)); - companions = writeMcsCompanions({ behaviorsDir, folder, prefix: schemaPrefix, description, payloadRel }); + companions = writeMcsCompanions({ behaviorsDir, folder, prefix: schemaPrefix, description, payloadRel, warnings }); } } @@ -642,9 +714,17 @@ async function main() { throw new Error(`Unknown command: ${cmd || '(none)'}. Use "list", "download" or "import".`); } -main().catch((err) => { - process.stdout.write(JSON.stringify({ ok: false, error: err && err.message ? err.message : String(err) }) + '\n'); - // Signal failure via exit code rather than process.exit(): a forced exit while - // fetch keep-alive sockets are still closing trips a libuv assertion on Windows. - process.exitCode = 1; -}); +if (require.main === module) { + main().catch((err) => { + process.stdout.write(JSON.stringify({ ok: false, error: err && err.message ? err.message : String(err) }) + '\n'); + // Signal failure via exit code rather than process.exit(): a forced exit while + // fetch keep-alive sockets are still closing trips a libuv assertion on Windows. + process.exitCode = 1; + }); +} + +module.exports = { + SCHEMA_NAME_MAX, + importSkill, + yamlScalar, +}; diff --git a/scripts/test/add-skill.test.js b/scripts/test/add-skill.test.js new file mode 100644 index 0000000..d4ddbd3 --- /dev/null +++ b/scripts/test/add-skill.test.js @@ -0,0 +1,121 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const yaml = require("js-yaml"); + +const { SCHEMA_NAME_MAX, importSkill, yamlScalar } = require("../add-skill"); + +// Build a throwaway cloned-agent workspace plus a source skill folder, so each +// test exercises the real importSkill path end to end. +function makeFixture({ schemaName, payload = {} }) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "add-skill-test-")); + const workspace = path.join(root, "agent"); + const src = path.join(root, "skill-src"); + fs.mkdirSync(workspace, { recursive: true }); + fs.mkdirSync(src, { recursive: true }); + fs.writeFileSync(path.join(workspace, "agent.sync.yaml"), "kind: CLICopilotRecognizer\n"); + fs.writeFileSync(path.join(workspace, "settings.mcs.yml"), `schemaName: ${schemaName}\nkind: CLICopilotRecognizer\n`); + fs.writeFileSync( + path.join(src, "SKILL.md"), + "---\nname: demo\ndescription: A demo skill.\n---\nBody.\n" + ); + for (const [rel, content] of Object.entries(payload)) { + const abs = path.join(src, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + } + return { root, workspace, src }; +} + +function readAnchor(workspace, folder) { + return yaml.load( + fs.readFileSync(path.join(workspace, "behaviors", folder, "skill.mcs.yml"), "utf8") + ); +} + +test("quotes plain scalars that a YAML 1.1 parser would read as booleans", () => { + assert.equal(yamlScalar("on"), '"on"'); + assert.equal(yamlScalar("no"), '"no"'); + assert.equal(yamlScalar("TRUE"), '"TRUE"'); +}); + +test("quotes plain scalars that would be read as numbers or null", () => { + assert.equal(yamlScalar("123"), '"123"'); + assert.equal(yamlScalar("1.0"), '"1.0"'); + assert.equal(yamlScalar("null"), '"null"'); + assert.equal(yamlScalar("~"), '"~"'); +}); + +test("leaves an unambiguous scalar unquoted", () => { + assert.equal(yamlScalar("make-restaurant-reservation"), "make-restaurant-reservation"); +}); + +test("keeps a numeric skill folder name a string in the anchor componentName", () => { + const { workspace, src } = makeFixture({ schemaName: "crbab_demo_dcF_b3" }); + const result = importSkill({ src, workspace, name: "123" }); + const anchor = readAnchor(workspace, result.folder); + assert.equal(anchor["mcs.metadata"].componentName, "123"); +}); + +test("keeps a payload path containing a YAML comment marker intact in its sidecar", () => { + const { workspace, src } = makeFixture({ + schemaName: "crbab_demo_dcF_b3", + payload: { "scripts/run #1.py": "print('hi')\n" }, + }); + const result = importSkill({ src, workspace, name: "demo" }); + const sidecar = yaml.load( + fs.readFileSync(path.join(workspace, "behaviors", result.folder, "scripts", "run #1.py.mcs.yml"), "utf8") + ); + assert.equal(sidecar["mcs.metadata"].componentName, "./scripts/run #1.py"); +}); + +test("keeps the anchor schemaName within the Dataverse budget for a long agent prefix", () => { + const schemaName = "crbab_averylongagentschemaname_abcdefghij"; + const { workspace, src } = makeFixture({ schemaName }); + const result = importSkill({ src, workspace, name: "a".repeat(60) }); + const anchor = readAnchor(workspace, result.folder); + assert.ok( + anchor["mcs.metadata"].schemaName.length <= SCHEMA_NAME_MAX, + `schemaName was ${anchor["mcs.metadata"].schemaName.length} chars: ${anchor["mcs.metadata"].schemaName}` + ); +}); + +test("keeps a file sidecar schemaName within the Dataverse budget for a long filename", () => { + const schemaName = "crbab_averylongagentschemaname_abcdefghij"; + const { workspace, src } = makeFixture({ + schemaName, + payload: { [`${"b".repeat(70)}.py`]: "print('hi')\n" }, + }); + const result = importSkill({ src, workspace, name: "demo" }); + const sidecar = yaml.load( + fs.readFileSync( + path.join(workspace, "behaviors", result.folder, `${"b".repeat(70)}.py.mcs.yml`), + "utf8" + ) + ); + assert.ok( + sidecar["mcs.metadata"].schemaName.length <= SCHEMA_NAME_MAX, + `schemaName was ${sidecar["mcs.metadata"].schemaName.length} chars` + ); +}); + +test("warns when a schema segment had to be truncated to fit the budget", () => { + const schemaName = "crbab_averylongagentschemaname_abcdefghij"; + const { workspace, src } = makeFixture({ schemaName }); + const result = importSkill({ src, workspace, name: "c".repeat(60) }); + assert.ok( + result.warnings.some((w) => /truncat/i.test(w)), + `expected a truncation warning, got: ${JSON.stringify(result.warnings)}` + ); +}); + +test("reports a clear error when the agent schema name leaves no component-name space", () => { + const schemaName = `crbab_${"z".repeat(89)}`; + const { workspace, src } = makeFixture({ schemaName }); + assert.throws( + () => importSkill({ src, workspace, name: "demo" }), + /leaves no valid component-name space/i + ); +}); From b515574005adcc4ebb97d6fe66132639bf11a001 Mon Sep 17 00:00:00 2001 From: anderson-joyle Date: Thu, 17 Sep 2026 15:01:13 -0500 Subject: [PATCH 4/4] Harden skill import against malformed schema prefixes and payload loss Fixes the actionable findings from the PR review bots: - readAgentSchemaPrefix now resolves the settings.mcs.yml value the way a YAML parser would. A quoted prefix used to be captured with its quotes and pasted straight into the anchor, producing both an invalid schema name and invalid YAML; a trailing inline comment used to defeat the match entirely and silently downgrade the import to a bare skill. The result is validated against the Dataverse prefix grammar before use. - The skill anchor's schema segment is now reduced to alphanumerics, matching SkillLayout.MintBundleSchemaName. A dotted --name previously smuggled an extra level into .skill.. - Payload archives are copied verbatim again. Only a root-level .zip is skipped, because it collides with the bundle the extension mints, and that skip is now reported instead of silent. - Sidecar exclusion (metadata.*, README.md) is case-insensitive, so a Metadata.json no longer leaks into the imported skill. - readMetadata only swallows 404s; a rate limit or outage now fails the list instead of quietly returning a short gallery. - download clears a previous copy of the slug first, so files deleted upstream or left by a half-finished run do not linger, guarded by a containment check on the destination. - The pretty list no longer truncates slugs, and --all drops the inaccurate "for Copilot Studio" qualifier from its heading. - Test fixtures clean up their mkdtemp directories. reference/skill-schema.md and commands/add-skill.md are updated to match, including the .zip upload flow, which now asks the user to extract the archive rather than describing a step the command's allowed-tools cannot perform. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f42cd36d-11db-48e7-a770-959e3d0e40d7 --- commands/add-skill.md | 23 ++-- reference/skill-schema.md | 30 ++++- scripts/add-skill.js | 126 ++++++++++++++++----- scripts/test/add-skill.test.js | 199 ++++++++++++++++++++++++++++++++- 4 files changed, 337 insertions(+), 41 deletions(-) diff --git a/commands/add-skill.md b/commands/add-skill.md index 070cb41..be58be0 100644 --- a/commands/add-skill.md +++ b/commands/add-skill.md @@ -74,8 +74,9 @@ about paths on the **gallery** branch until it is time to pick a destination (st 2. Validate it exists and is a single `SKILL.md` (Markdown) or a `.zip`. If it is neither, tell the user what is accepted and stop. 3. Confirm the resolved absolute path. For a `SKILL.md`, its containing folder is the `--src` for - import (step 5). For a `.zip`, extract it to a folder first so `--src` points at the extracted - `SKILL.md` and any payload files. + import (step 5). For a `.zip`, you cannot extract it yourself - the only shell command available + here is the `add-skill.js` script. Ask the user to extract it and give you the path to the + extracted folder (the one holding `SKILL.md`), then use that as `--src`. ## 4. Select from the gallery @@ -124,9 +125,10 @@ payload file — so the on-disk layout matches a Copilot Studio portal import. T shapes; `reference/skill-schema.md` documents them. Every component's schema name is prefixed with the agent `schemaName` read from the workspace -`settings.mcs.yml`. If that prefix cannot be determined - or you pass `--no-sidecars` - the import -falls back to a **bare `behaviors//` folder** and the extension synthesizes the companions on -the next workspace read / sync (a `warning` explains which happened). If the agent `schemaName` is so +`settings.mcs.yml`. If that value is missing, or is not a usable Dataverse prefix - or you pass +`--no-sidecars` - the import falls back to a **bare `behaviors//` folder** and the extension +synthesizes the companions on the next workspace read / sync. A `warning` explains the fallback +except when you asked for it with `--no-sidecars`. If the agent `schemaName` is so long that it leaves no room for a component name under the 100-character Dataverse limit, the import fails before writing anything - relay that error and suggest a shorter agent schema name. @@ -140,8 +142,12 @@ node "" import --src "" --workspace "/`. - `--no-sidecars` skips companion generation and writes a bare skill (the pre-existing behavior). -- Gallery sidecars (`metadata.json`, `README.md`) are excluded by default; pass `--include-sidecars` - to keep them. Any `.mcs.yml` files already in `--src` are ignored and regenerated. +- Gallery sidecars (`metadata.json`, `metadata.yaml`, `metadata.yml`, `README.md`) are excluded by + default, matched case-insensitively at the skill root; pass `--include-sidecars` to keep them. Any + `.mcs.yml` files already in `--src` are ignored and regenerated. +- Archives inside the payload (e.g. `assets/templates.zip`) are copied like any other file. A + root-level `.zip` is skipped, because it would collide with the bundle archive the extension + mints for the skill; the import reports that in `warnings`. The result JSON is `{ ok, folder, workspace, behaviorsDir, manifest, files, companions, schemaPrefix, warnings, next }`. `companions` lists the `.mcs.yml` files written (empty when bare) and `schemaPrefix` is the agent @@ -149,7 +155,8 @@ schema used. Relay any `warnings` (e.g. the target does not look like a cloned C schema prefix could not be read) - the files are still written, but a non-agent workspace will not pick the skill up. -For a `.zip` upload, unzip it to a folder first (so `--src` points at the extracted `SKILL.md` + files). +For a `.zip` upload, `--src` must point at the folder the **user** extracted it to (see step 3), so +that it holds the `SKILL.md` plus any payload files. ## 6. Report diff --git a/reference/skill-schema.md b/reference/skill-schema.md index 157e4f0..6a9588d 100644 --- a/reference/skill-schema.md +++ b/reference/skill-schema.md @@ -74,7 +74,7 @@ content: | mcs.metadata: componentName: pdf-redline description: Redlines a PDF contract and produces a summary of changes. - schemaName: crbab_guitarcoach_dcF_b3.skill.pdf-redline_Kq2 + schemaName: crbab_guitarcoach_dcF_b3.skill.pdfredline_Kq2 bundle: crbab_guitarcoach_dcF_b3.file.pdfredlinezip_9Xa2L manifestSchemaName: crbab_guitarcoach_dcF_b3.file.skillmd_bT4nQ kind: InlineAgentSkill @@ -149,6 +149,9 @@ segment, so keep it filesystem- and Dataverse-safe: with `skill-`. - Cap the result at 60 characters. This is a readability cap, **not** the schema limit — see below. +Dots and dashes survive here on purpose — they read well as a display name — so the schema segment is +derived separately (see below) rather than reusing the folder name as-is. + ## Schema names Every component schema name has the shape: @@ -159,13 +162,29 @@ Every component schema name has the shape: | Component | `` | `` | `` | |---|---|---|---| -| Skill anchor | `skill` | the `behaviors/` folder name | 3 chars | +| Skill anchor | `skill` | the `behaviors/` folder name with every non-alphanumeric removed (`my.cool-skill` → `mycoolskill`), falling back to `skill` when that empties it | 3 chars | | Manifest, bundle, payload file | `file` | the file name, lowercased, non-alphanumerics removed (`SKILL.md` → `skillmd`, `redline.py` → `redlinepy`) | 5 chars | `` is read from the workspace `settings.mcs.yml` (e.g. `crbab_guitarcoach_dcF_b3`). `` is a random base62 string; its length is cosmetic parity with portal output — only validity and uniqueness matter. +Both segments are reduced to alphanumerics because a `.` in a segment would silently add a fourth +schema-name level, and the extension mints its own segments the same way +(`SkillLayout.MintBundleSchemaName` keeps only ASCII letters and digits). + +### Reading the agent schema name + +`settings.mcs.yml` is scraped, not fully parsed, so resolve the `schemaName:` value the way a YAML +parser would before using it as a prefix: + +- Honour double and single quoting, and unescape the contents. `schemaName: "crbab_x"` is the prefix + `crbab_x`, never `"crbab_x"` — pasting the quotes through produces an invalid schema name *and* + invalid YAML in the anchor. +- Drop a trailing `#` comment on an unquoted value. +- Require the result to match `^[A-Za-z][A-Za-z0-9_]*$`. Anything else is not a usable Dataverse + prefix; treat it as "no prefix" and fall back to a bare skill rather than emitting it verbatim. + ### 100-character schema-name budget Dataverse caps a schema name at 100 characters. Apply the limit **before** writing: @@ -185,8 +204,11 @@ The 60-character folder cap is not sufficient on its own: a 40-character agent s - Never overwrite an existing `behaviors//` without explicit confirmation (`--force`). - Regenerate companions; never copy a `.mcs.yml` from the skill source. -- Gallery sidecars (`metadata.json`, `metadata.yaml`, `metadata.yml`, `README.md`) and any `.zip` are - excluded from the payload by default. +- Gallery sidecars (`metadata.json`, `metadata.yaml`, `metadata.yml`, `README.md`) are excluded from + the payload by default, matched **case-insensitively** and only at the skill root. +- Payload archives are copied verbatim like any other file. The one exception is a root-level + `.zip`, which would collide with the bundle archive the extension mints for the skill: + skip it and say so, rather than dropping it silently. - The manifest must land as exactly `SKILL.md`. If the source has several root manifests differing only by casing, keep one and report the one skipped. diff --git a/scripts/add-skill.js b/scripts/add-skill.js index 67be123..5a456d9 100644 --- a/scripts/add-skill.js +++ b/scripts/add-skill.js @@ -57,7 +57,12 @@ const API_BASE = `https://api.github.com/repos/${REPO}`; const PAGES_BASE = 'https://microsoft.github.io/cat-agent-skills'; // Sidecar files live next to the payload but are never part of the skill itself. -const SIDECARS = new Set(['metadata.json', 'metadata.yaml', 'metadata.yml', 'README.md']); +// Matched case-insensitively: the gallery and local uploads spell these both ways. +const SIDECARS = new Set(['metadata.json', 'metadata.yaml', 'metadata.yml', 'readme.md']); + +function isSidecarLeaf(leaf) { + return SIDECARS.has(String(leaf || '').toLowerCase()); +} // Scaffolding folders in the gallery that are not real submissions. const TEMPLATES = new Set(['_template', '_template-automation']); @@ -95,9 +100,15 @@ function ghHeaders() { return headers; } +function httpError(url, res) { + const err = new Error(`GET ${url} -> ${res.status} ${res.statusText}`); + err.status = res.status; + return err; +} + async function fetchText(url, headers) { const res = await fetch(url, headers ? { headers } : undefined); - if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`); + if (!res.ok) throw httpError(url, res); return res.text(); } @@ -107,7 +118,7 @@ async function fetchJson(url, headers) { async function fetchBuffer(url) { const res = await fetch(url); - if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`); + if (!res.ok) throw httpError(url, res); return Buffer.from(await res.arrayBuffer()); } @@ -154,7 +165,7 @@ function isSkillFile(rel) { // Files that make up the downloadable skill (everything except sidecars). function payloadFiles(files) { - return files.filter((f) => !SIDECARS.has(topName(f))); + return files.filter((f) => !isSidecarLeaf(topName(f))); } function classify(files) { @@ -174,8 +185,18 @@ function classify(files) { } async function readMetadata(slug) { + let text; try { - return JSON.parse(await fetchText(`${RAW_BASE}/submissions/${slug}/metadata.json`)); + text = await fetchText(`${RAW_BASE}/submissions/${slug}/metadata.json`); + } catch (e) { + // A missing metadata.json simply means "not a listable skill". Any other + // failure (rate limit, outage) must surface instead of silently shrinking + // the gallery listing to whatever happened to succeed. + if (e && e.status === 404) return null; + throw e; + } + try { + return JSON.parse(text); } catch { return null; } @@ -229,7 +250,15 @@ async function downloadSkill(slug, dest) { throw new Error(`"${slug}" is a ${info.type} entry, not an unpacked Agent Skill; cannot add it to a Copilot Studio agent.`); } - const skillDir = path.join(dest, slug); + const skillDir = path.resolve(dest, slug); + const destRoot = path.resolve(dest); + if (skillDir !== destRoot && !skillDir.startsWith(destRoot + path.sep)) { + throw new Error(`Refusing to download "${slug}": it does not resolve to a folder inside ${destRoot}.`); + } + // A download reflects the gallery as it is now, so clear any earlier copy first: + // otherwise files deleted upstream (or left behind by a half-finished run) linger + // and get imported as if they were still part of the skill. + fs.rmSync(skillDir, { recursive: true, force: true }); fs.mkdirSync(skillDir, { recursive: true }); const saved = []; @@ -357,16 +386,52 @@ function fitSchemaSegment(segment, { prefix, infix, tokenLen, label, warnings }) return cut; } +// A Dataverse schema name is a publisher prefix plus an alphanumeric/underscore +// name. Anything else (quotes left in, a stray comment, a sentence) cannot anchor +// a component name, so we treat it as "no prefix" rather than emit it verbatim. +const SCHEMA_PREFIX_RE = /^[A-Za-z][A-Za-z0-9_]*$/; + +// Resolve a single-line YAML scalar the way a parser would: honour quoting, and +// drop a trailing `#` comment on plain scalars. Used for the values this script +// scrapes out of settings.mcs.yml and SKILL.md frontmatter without a full parse. +function plainYamlValue(rawValue) { + const v = String(rawValue == null ? '' : rawValue).trim(); + if (v[0] === '"') { + const m = v.match(/^"((?:[^"\\]|\\.)*)"/); + return m ? m[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\') : ''; + } + if (v[0] === "'") { + const m = v.match(/^'((?:[^']|'')*)'/); + return m ? m[1].replace(/''/g, "'") : ''; + } + const comment = v.match(/(?:^|\s)#/); + return (comment ? v.slice(0, comment.index) : v).trim(); +} + +// The extension mints component segments from ASCII letters and digits only +// (SkillLayout.MintBundleSchemaName), so the folder name has to be reduced the +// same way. Without this, a folder like "foo.bar" would smuggle an extra dot into +// `.skill.` and produce a four-segment schema name. +function skillSchemaSegment(folder) { + const seg = String(folder || '').replace(/[^A-Za-z0-9]+/g, ''); + return seg.length === 0 ? 'skill' : seg; +} + // The agent schemaName is the prefix every component hangs off; read it from the -// workspace settings file. Returns null when it cannot be determined. +// workspace settings file. Returns null when it cannot be determined, or when the +// value is not a usable Dataverse prefix — a prefix we cannot trust would produce +// component names Dataverse rejects. function readAgentSchemaPrefix(wsDir) { + let raw; try { - const raw = fs.readFileSync(path.join(wsDir, SETTINGS_FILE), 'utf8'); - const m = raw.match(/^\uFEFF?schemaName:[ \t]*(\S+)[ \t]*$/m); - return m ? m[1] : null; + raw = fs.readFileSync(path.join(wsDir, SETTINGS_FILE), 'utf8'); } catch { return null; } + const m = raw.replace(/^\uFEFF/, '').match(/^schemaName:[ \t]*(.*)$/m); + if (!m) return null; + const value = plainYamlValue(m[1]); + return SCHEMA_PREFIX_RE.test(value) ? value : null; } // Pull the `description` from the SKILL.md YAML frontmatter (single-line scalar). @@ -383,13 +448,7 @@ function readManifestDescription(manifestAbs) { if (!fm) return ''; const m = fm[1].match(/^description:[ \t]*(.*)$/m); if (!m) return ''; - let val = m[1].trim(); - if (val.length >= 2 && val[0] === '"' && val.endsWith('"')) { - val = val.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\'); - } else if (val.length >= 2 && val[0] === "'" && val.endsWith("'")) { - val = val.slice(1, -1).replace(/''/g, "'"); - } - return val; + return plainYamlValue(m[1]); } // Plain scalars a YAML 1.1 parser (what the Copilot Studio tooling reads these @@ -440,7 +499,7 @@ function writeMcsCompanions({ behaviorsDir, folder, prefix, description, payload const anchorLines = ['mcs.metadata:', ` componentName: ${yamlScalar(folder)}`]; if (description) anchorLines.push(` description: ${yamlScalar(description)}`); - const skillSeg = fitSchemaSegment(folder, { + const skillSeg = fitSchemaSegment(skillSchemaSegment(folder), { prefix, infix: 'skill', tokenLen: 3, label: `the skill anchor "${folder}"`, warnings, }); anchorLines.push(` schemaName: ${prefix}.skill.${skillSeg}_${schemaToken(3)}`); @@ -534,11 +593,18 @@ function importSkill({ src, workspace, name, force, includeSidecars, noSidecars } const written = []; + const skippedBundleZips = []; let extraManifestSkipped = false; for (const rel of allFiles) { const leaf = rel.split('/').pop(); - if (!includeSidecars && rel.indexOf('/') < 0 && SIDECARS.has(leaf)) continue; - if (leaf.toLowerCase().endsWith('.zip')) continue; + if (!includeSidecars && rel.indexOf('/') < 0 && isSidecarLeaf(leaf)) continue; + // The extension mints this skill's bundle as ".zip", so a root-level + // source file with that exact name would collide with it. Every other archive + // is ordinary payload and is copied verbatim. + if (rel.indexOf('/') < 0 && leaf.toLowerCase() === `${folder.toLowerCase()}.zip`) { + skippedBundleZips.push(rel); + continue; + } // MCS companions are (re)generated below, never copied from the source. if (leaf.toLowerCase().endsWith(MCS_SIDECAR_SUFFIX)) continue; // The manifest must land as exactly SKILL.md; skip any duplicate-cased root manifest. @@ -555,6 +621,9 @@ function importSkill({ src, workspace, name, force, includeSidecars, noSidecars if (extraManifestSkipped) { warnings.push(`Multiple root manifest files found; kept "${manifestRel}" as ${MANIFEST_NAME} and skipped the other.`); } + for (const rel of skippedBundleZips) { + warnings.push(`Skipped "${rel}": it collides with the bundle archive the extension mints for "${folder}". Rename it (or move it into a subfolder) to ship it as payload.`); + } // Emit portal-style .mcs.yml companions so the on-disk layout matches a Copilot // Studio portal import. Requires the agent schema prefix from settings.mcs.yml; @@ -563,7 +632,7 @@ function importSkill({ src, workspace, name, force, includeSidecars, noSidecars let companions = []; if (!noSidecars) { if (!schemaPrefix) { - warnings.push(`Could not read the agent schemaName from ${SETTINGS_FILE}; wrote a bare ${BEHAVIORS_DIR}/ skill without .mcs.yml companions (the extension will synthesize them on pull).`); + warnings.push(`Could not read a usable agent schemaName from ${SETTINGS_FILE}; wrote a bare ${BEHAVIORS_DIR}/ skill without .mcs.yml companions (the extension will synthesize them on pull).`); } else { const prefixLen = `${BEHAVIORS_DIR}/${folder}/`.length; const payloadRel = written @@ -608,15 +677,16 @@ function fitCell(text, width, align) { return align === 'right' ? pad + s : s + pad; } -function renderPrettyList(skills, { width = PRETTY_WIDTH } = {}) { +function renderPrettyList(skills, { width = PRETTY_WIDTH, all = false } = {}) { const term = Math.max(72, Math.min(width, 200)); const wNum = Math.max(2, String(skills.length).length); const wBundle = 1; const wName = 28; // Slugs are the exact key used for `download --slug`, so never truncate them: - // size the column to the longest slug (bounded) and let Description flex. + // size the column to the longest slug and let Description flex (down to its + // floor, after which the table simply renders wider than the nominal width). const maxSlug = skills.reduce((m, s) => Math.max(m, String(s.slug || '').length), 0); - const wSlug = Math.min(42, Math.max(8, maxSlug)); + const wSlug = Math.max(8, maxSlug); // Each column renders as "│ " (1 border + 2 spaces = 3 chars) and the // row ends with a trailing "│": 5 columns => 5*3 + 1 = 16 chars of chrome. const chrome = 5 * 3 + 1; @@ -645,7 +715,7 @@ function renderPrettyList(skills, { width = PRETTY_WIDTH } = {}) { }); out.push(rule('└', '┴', '┘')); - const heading = `Cat Agent Skills ${skills.length} skills for Copilot Studio`; + const heading = `Cat Agent Skills ${skills.length} skills${all ? '' : ' for Copilot Studio'}`; const legend = '◆ = bundle (ships scripts / references / assets) · others are a single SKILL.md'; return `\n${heading}\n\n${out.join('\n')}\n\n${legend}`; @@ -678,7 +748,7 @@ async function main() { if (cmd === 'list') { const skills = await listSkills({ all: !!args.all }); if (args.pretty) { - process.stdout.write(renderPrettyList(skills) + '\n'); + process.stdout.write(renderPrettyList(skills, { all: !!args.all }) + '\n'); return; } process.stdout.write(JSON.stringify({ ok: true, count: skills.length, skills }, null, 2) + '\n'); @@ -725,6 +795,10 @@ if (require.main === module) { module.exports = { SCHEMA_NAME_MAX, + downloadSkill, importSkill, + listSkills, + readAgentSchemaPrefix, + renderPrettyList, yamlScalar, }; diff --git a/scripts/test/add-skill.test.js b/scripts/test/add-skill.test.js index d4ddbd3..36e43a3 100644 --- a/scripts/test/add-skill.test.js +++ b/scripts/test/add-skill.test.js @@ -3,20 +3,40 @@ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const test = require("node:test"); +const { after } = require("node:test"); const yaml = require("js-yaml"); -const { SCHEMA_NAME_MAX, importSkill, yamlScalar } = require("../add-skill"); +const { + SCHEMA_NAME_MAX, + downloadSkill, + importSkill, + listSkills, + readAgentSchemaPrefix, + renderPrettyList, + yamlScalar, +} = require("../add-skill"); + +// Every fixture root created during the run, removed once the file is done so a +// test pass does not leave a trail of mkdtemp directories behind in the OS temp dir. +const fixtureRoots = []; +after(() => { + for (const root of fixtureRoots) fs.rmSync(root, { recursive: true, force: true }); +}); // Build a throwaway cloned-agent workspace plus a source skill folder, so each // test exercises the real importSkill path end to end. -function makeFixture({ schemaName, payload = {} }) { +function makeFixture({ schemaName, settingsLine, payload = {} }) { const root = fs.mkdtempSync(path.join(os.tmpdir(), "add-skill-test-")); + fixtureRoots.push(root); const workspace = path.join(root, "agent"); const src = path.join(root, "skill-src"); fs.mkdirSync(workspace, { recursive: true }); fs.mkdirSync(src, { recursive: true }); fs.writeFileSync(path.join(workspace, "agent.sync.yaml"), "kind: CLICopilotRecognizer\n"); - fs.writeFileSync(path.join(workspace, "settings.mcs.yml"), `schemaName: ${schemaName}\nkind: CLICopilotRecognizer\n`); + fs.writeFileSync( + path.join(workspace, "settings.mcs.yml"), + `${settingsLine || `schemaName: ${schemaName}`}\nkind: CLICopilotRecognizer\n` + ); fs.writeFileSync( path.join(src, "SKILL.md"), "---\nname: demo\ndescription: A demo skill.\n---\nBody.\n" @@ -119,3 +139,176 @@ test("reports a clear error when the agent schema name leaves no component-name /leaves no valid component-name space/i ); }); + +// --- Agent schema prefix parsing ------------------------------------------- + +test("reads a double-quoted agent schemaName without the quotes", () => { + const { workspace } = makeFixture({ settingsLine: 'schemaName: "crbab_demo_dcF_b3"' }); + assert.equal(readAgentSchemaPrefix(workspace), "crbab_demo_dcF_b3"); +}); + +test("reads a single-quoted agent schemaName without the quotes", () => { + const { workspace } = makeFixture({ settingsLine: "schemaName: 'crbab_demo_dcF_b3'" }); + assert.equal(readAgentSchemaPrefix(workspace), "crbab_demo_dcF_b3"); +}); + +test("ignores a trailing inline comment on the agent schemaName", () => { + const { workspace } = makeFixture({ settingsLine: "schemaName: crbab_demo_dcF_b3 # the prefix" }); + assert.equal(readAgentSchemaPrefix(workspace), "crbab_demo_dcF_b3"); +}); + +test("rejects an agent schemaName that is not a usable Dataverse prefix", () => { + const { workspace } = makeFixture({ settingsLine: "schemaName: not a prefix!" }); + assert.equal(readAgentSchemaPrefix(workspace), null); +}); + +test("emits a valid anchor schemaName when settings quotes the agent schemaName", () => { + const { workspace, src } = makeFixture({ settingsLine: 'schemaName: "crbab_demo_dcF_b3"' }); + const result = importSkill({ src, workspace, name: "demo" }); + const anchor = readAnchor(workspace, result.folder); + assert.match(anchor["mcs.metadata"].schemaName, /^crbab_demo_dcF_b3\.skill\.demo_[A-Za-z0-9]{3}$/); +}); + +test("still writes companions when settings comments the agent schemaName", () => { + const { workspace, src } = makeFixture({ settingsLine: "schemaName: crbab_demo_dcF_b3 # prefix" }); + const result = importSkill({ src, workspace, name: "demo" }); + assert.equal(result.schemaPrefix, "crbab_demo_dcF_b3"); + assert.ok(result.companions.length > 0, "expected .mcs.yml companions to be written"); +}); + +// --- Schema name shape ------------------------------------------------------ + +test("keeps a dotted skill name from adding extra schema-name segments", () => { + const { workspace, src } = makeFixture({ schemaName: "crbab_demo_dcF_b3" }); + const result = importSkill({ src, workspace, name: "foo.bar" }); + const anchor = readAnchor(workspace, result.folder); + assert.equal( + anchor["mcs.metadata"].schemaName.split(".").length, + 3, + `schemaName had extra segments: ${anchor["mcs.metadata"].schemaName}` + ); +}); + +test("strips non-alphanumerics from the anchor schema segment like the extension does", () => { + const { workspace, src } = makeFixture({ schemaName: "crbab_demo_dcF_b3" }); + const result = importSkill({ src, workspace, name: "my-cool.skill" }); + const anchor = readAnchor(workspace, result.folder); + assert.match(anchor["mcs.metadata"].schemaName, /^crbab_demo_dcF_b3\.skill\.mycoolskill_[A-Za-z0-9]{3}$/); +}); + +// --- Payload fidelity ------------------------------------------------------- + +test("copies a nested .zip payload file verbatim", () => { + const { workspace, src } = makeFixture({ + schemaName: "crbab_demo_dcF_b3", + payload: { "assets/templates.zip": "PK\u0003\u0004stub" }, + }); + const result = importSkill({ src, workspace, name: "demo" }); + assert.ok( + fs.existsSync(path.join(workspace, "behaviors", result.folder, "assets", "templates.zip")), + `assets/templates.zip was dropped; got ${JSON.stringify(result.files)}` + ); +}); + +test("skips a root bundle zip that collides with the minted bundle name, and says so", () => { + const { workspace, src } = makeFixture({ + schemaName: "crbab_demo_dcF_b3", + payload: { "demo.zip": "PK\u0003\u0004stub" }, + }); + const result = importSkill({ src, workspace, name: "demo" }); + assert.ok(!fs.existsSync(path.join(workspace, "behaviors", result.folder, "demo.zip"))); + assert.ok( + result.warnings.some((w) => /demo\.zip/.test(w)), + `expected a warning naming the skipped zip, got: ${JSON.stringify(result.warnings)}` + ); +}); + +test("excludes a mixed-case sidecar file the same way as its lowercase spelling", () => { + const { workspace, src } = makeFixture({ + schemaName: "crbab_demo_dcF_b3", + payload: { "Metadata.json": "{}\n", "README.MD": "# readme\n" }, + }); + const result = importSkill({ src, workspace, name: "demo" }); + const dir = path.join(workspace, "behaviors", result.folder); + assert.ok(!fs.existsSync(path.join(dir, "Metadata.json")), "Metadata.json should be excluded"); + assert.ok(!fs.existsSync(path.join(dir, "README.MD")), "README.MD should be excluded"); +}); + +// --- Pretty listing --------------------------------------------------------- + +test("never truncates a slug in the pretty table", () => { + const slug = "a-really-long-gallery-submission-slug-that-exceeds-the-old-cap"; + const out = renderPrettyList([{ slug, name: "Long", description: "d", hasBundle: false }]); + assert.ok(out.includes(slug), `slug was truncated:\n${out}`); +}); + +test("drops the Copilot Studio qualifier from the heading when listing every platform", () => { + const skills = [{ slug: "s", name: "S", description: "d", hasBundle: false }]; + assert.match(renderPrettyList(skills, { all: true }), /1 skills\b/); + assert.ok(!renderPrettyList(skills, { all: true }).includes("for Copilot Studio")); + assert.ok(renderPrettyList(skills).includes("for Copilot Studio")); +}); + +// --- Gallery fetch behaviour ------------------------------------------------ + +// Serve the gallery from an in-memory tree so download/list run without network. +function stubGallery(t, { files, status = {} }) { + const real = global.fetch; + t.after(() => { global.fetch = real; }); + global.fetch = async (url) => { + const u = String(url); + const fail = Object.keys(status).find((k) => u.includes(k)); + if (fail) { + const code = status[fail]; + return { ok: false, status: code, statusText: `status ${code}`, text: async () => "" }; + } + if (u.includes("/git/trees/")) { + const body = JSON.stringify({ + truncated: false, + tree: Object.keys(files).map((p) => ({ type: "blob", path: p })), + }); + return { ok: true, status: 200, statusText: "OK", text: async () => body }; + } + const m = u.match(/\/main\/(submissions\/.+)$/); + if (m && files[m[1]] !== undefined) { + const body = files[m[1]]; + return { + ok: true, status: 200, statusText: "OK", + text: async () => body, + arrayBuffer: async () => Buffer.from(body), + }; + } + return { ok: false, status: 404, statusText: "Not Found", text: async () => "" }; + }; +} + +test("surfaces a non-404 metadata failure instead of silently dropping the skill", async (t) => { + stubGallery(t, { + files: { + "submissions/demo/SKILL.md": "---\nname: demo\n---\n", + "submissions/demo/metadata.json": JSON.stringify({ name: "Demo", platforms: ["Copilot Studio"] }), + }, + status: { "submissions/demo/metadata.json": 500 }, + }); + await assert.rejects(() => listSkills({ all: true }), /500/); +}); + +test("removes files left over from a previous download of the same slug", async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "add-skill-test-")); + fixtureRoots.push(root); + const files = { + "submissions/demo/SKILL.md": "---\nname: demo\n---\n", + "submissions/demo/metadata.json": JSON.stringify({ name: "Demo", platforms: ["Copilot Studio"] }), + "submissions/demo/scripts/old.py": "print('old')\n", + }; + stubGallery(t, { files }); + await downloadSkill("demo", root); + assert.ok(fs.existsSync(path.join(root, "demo", "scripts", "old.py"))); + + delete files["submissions/demo/scripts/old.py"]; + await downloadSkill("demo", root); + assert.ok( + !fs.existsSync(path.join(root, "demo", "scripts", "old.py")), + "stale scripts/old.py survived a re-download" + ); +});