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 new file mode 100644 index 0000000..be58be0 --- /dev/null +++ b/commands/add-skill.md @@ -0,0 +1,182 @@ +--- +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 +--- + +# 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. + +**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 + +## 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) + +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 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. For a `SKILL.md`, its containing folder is the `--src` for + 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 + +1. List the available skills as a **table** (box structure), which is the format to show the user: + + ```bash + node "" list --pretty + ``` + + 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 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. **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 "" + ``` + + 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** — 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 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. + +```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`, `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 +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, `--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 + +Tell the user, concisely: + +- The skill name and slug. +- 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. +- 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 + +- 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/reference/skill-schema.md b/reference/skill-schema.md new file mode 100644 index 0000000..6a9588d --- /dev/null +++ b/reference/skill-schema.md @@ -0,0 +1,232 @@ +# 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.pdfredline_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. + +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: + +```text +.._ +``` + +| Component | `` | `` | `` | +|---|---|---|---| +| 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: + +- 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`) 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. + +## 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 new file mode 100644 index 0000000..5a456d9 --- /dev/null +++ b/scripts/add-skill.js @@ -0,0 +1,804 @@ +#!/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] [--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. Pass + * --pretty to render a plain, deterministic (fixed-width) box table instead + * of JSON. + * + * 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. + * + * 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'); +const path = require('path'); +const os = require('os'); +const crypto = require('crypto'); + +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. +// 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']); + +// 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'; +// 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', + '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', + Accept: 'application/vnd.github+json', + }; + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + if (token) headers.Authorization = `Bearer ${token}`; + 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 httpError(url, res); + 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 httpError(url, res); + 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) => !isSidecarLeaf(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) { + let text; + try { + 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; + } +} + +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))); + } + + // 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; +} + +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.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 = []; + 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, + }; +} + +// 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, ''); +} + +// 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; +} + +// 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, 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 { + 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). +// 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 ''; + return plainYamlValue(m[1]); +} + +// 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. +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) || + 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, '\\\\') + .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, warnings }) { + const isBundle = payloadRel.length > 0; + const written = []; + + const anchorLines = ['mcs.metadata:', ` componentName: ${yamlScalar(folder)}`]; + if (description) anchorLines.push(` description: ${yamlScalar(description)}`); + const skillSeg = fitSchemaSegment(skillSchemaSegment(folder), { + prefix, infix: 'skill', tokenLen: 3, label: `the skill anchor "${folder}"`, warnings, + }); + anchorLines.push(` schemaName: ${prefix}.skill.${skillSeg}_${schemaToken(3)}`); + if (isBundle) { + 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'); + // 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 fileSeg = fitSchemaSegment(fileSchemaSegment(leaf), { + prefix, infix: 'file', tokenLen: 5, label: `payload file "${rel}"`, warnings, + }); + const lines = [ + 'mcs.metadata:', + ` 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}`); + } + } + 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); + + // 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.`); + } + fs.rmSync(behaviorsDir, { recursive: true, force: true }); + } + + const written = []; + const skippedBundleZips = []; + let extraManifestSkipped = false; + for (const rel of allFiles) { + const leaf = rel.split('/').pop(); + 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. + 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.`); + } + 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; + // without it (or with --no-sidecars) we leave a bare behaviors/ skill for the + // extension to synthesize on pull. + let companions = []; + if (!noSidecars) { + if (!schemaPrefix) { + 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 + .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, warnings }); + } + } + + 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, 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 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.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${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}`; +} + +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 }); + if (args.pretty) { + process.stdout.write(renderPrettyList(skills, { all: !!args.all }) + '\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.'); + // 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; + } + + 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".`); +} + +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, + downloadSkill, + importSkill, + listSkills, + readAgentSchemaPrefix, + renderPrettyList, + yamlScalar, +}; diff --git a/scripts/test/add-skill.test.js b/scripts/test/add-skill.test.js new file mode 100644 index 0000000..36e43a3 --- /dev/null +++ b/scripts/test/add-skill.test.js @@ -0,0 +1,314 @@ +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 { after } = require("node:test"); +const yaml = require("js-yaml"); + +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, 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"), + `${settingsLine || `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 + ); +}); + +// --- 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" + ); +});