feat(agent-mode): bundle Obsidian-native skills

This commit is contained in:
Logan Yang 2026-07-21 21:06:09 -07:00
parent ee1b9269be
commit 4e572c122c
No known key found for this signature in database
7 changed files with 1438 additions and 408 deletions

View file

@ -42,6 +42,21 @@ The agent also has a maximum runtime of 5 minutes per response, regardless of it
Copilot Plus has 13 built-in tools. Some are always active; others can be enabled or disabled.
### Built-in Obsidian skills
Copilot also seeds four Obsidian-native skills for Claude, Codex, and OpenCode:
- **Obsidian Markdown** — wikilinks, embeds, block references, callouts, properties, tags, and comments.
- **Obsidian Bases** — valid `.base` schemas, filters, formulas, views, summaries, quoting, and date/duration behavior.
- **JSON Canvas** — the `.canvas` schema, nodes, edges, groups, layout, colors, IDs, and link integrity.
- **Obsidian CLI** — runtime and indexed operations such as currently open notes and tabs, workspace layout, daily notes, typed properties, tasks, backlinks, Bases queries, template resolution, link-aware moves, registered commands, and plugin debugging.
These skills appear under **Settings → Copilot → Skills**, where each one can be enabled or disabled per agent. Existing choices are preserved when Copilot refreshes a built-in skill.
The Obsidian CLI skill first verifies that `obsidian version` succeeds. The CLI requires a compatible Obsidian installer, registration, and a running Obsidian app; if it is unavailable, the agent falls back to normal filesystem operations where possible. Copilot does not change its minimum supported Obsidian version or attempt to install or repair the CLI.
When inspecting open tabs, the agent preserves Markdown notes, other file-backed tabs, and non-file views as workspace context. It reads content only from explicit vault paths reported by Obsidian and never treats a tab title or ID as a filename.
### Always-Enabled Tools
These tools are always available and cannot be disabled:

View file

@ -9,250 +9,262 @@ function scriptOf(name: string, ext: ".sh" | ".cmd" | ".ps1" = ".sh"): string {
return file.content;
}
describe("builtin Copilot Plus skills", () => {
it("ships the five Copilot-branded skills, each fanned out to all three agents", () => {
expect(BUILTIN_SKILLS.map((s) => s.name)).toEqual([
"copilot-web-search",
"copilot-web-fetch",
"copilot-read-pdf",
"copilot-youtube-transcript",
"copilot-fetch-x",
]);
for (const skill of BUILTIN_SKILLS) {
expect(skill.enabledAgents).toEqual(["claude", "codex", "opencode"]);
}
const PLUS_SKILLS = BUILTIN_SKILLS.filter((skill) => skill.name.startsWith("copilot-"));
describe("builtinSkills", () => {
describe("BUILTIN_SKILLS", () => {
it("ships the approved Plus and Obsidian skills to all three agents", () => {
expect(BUILTIN_SKILLS.map((s) => s.name)).toEqual([
"copilot-web-search",
"copilot-web-fetch",
"copilot-read-pdf",
"copilot-youtube-transcript",
"copilot-fetch-x",
"obsidian-markdown",
"obsidian-bases",
"json-canvas",
"obsidian-cli",
]);
for (const skill of BUILTIN_SKILLS) {
expect(skill.enabledAgents).toEqual(["claude", "codex", "opencode"]);
}
});
it("keeps the SKILL.md frontmatter version in sync with the numeric version", () => {
for (const skill of BUILTIN_SKILLS) {
expect(skill.skillMd).toContain(`copilot-builtin-version: "${skill.version}"`);
}
});
it("ships one runnable script per OS — POSIX sh + Windows cmd/ps1, no Node", () => {
for (const skill of PLUS_SKILLS) {
const sh = skill.files.find((f) => f.path.endsWith(".sh"));
const cmd = skill.files.find((f) => f.path.endsWith(".cmd"));
const ps1 = skill.files.find((f) => f.path.endsWith(".ps1"));
expect(sh).toBeDefined();
expect(cmd).toBeDefined();
expect(ps1).toBeDefined();
// The three scripts share a base name (web-search.sh ↔ .cmd ↔ .ps1).
expect(cmd!.path).toBe(sh!.path.replace(/\.sh$/, ".cmd"));
expect(ps1!.path).toBe(sh!.path.replace(/\.sh$/, ".ps1"));
// SKILL.md routes macOS/Linux at sh and Windows at the cmd wrapper (run
// with PowerShell's `&` call operator), with no Node anywhere.
expect(skill.skillMd).toContain(`sh "/absolute/path/to/this/skill/directory/${sh!.path}"`);
expect(skill.skillMd).toContain(`& "/absolute/path/to/this/skill/directory/${cmd!.path}"`);
expect(skill.skillMd).not.toContain("install Node.js");
expect(skill.skillMd).not.toContain("node ");
// No Node runtime ships anymore.
expect(skill.files.some((f) => f.path.endsWith(".mjs"))).toBe(false);
// The cmd launcher drives the sibling ps1 via Windows PowerShell with the
// execution policy relaxed, locating it relative to its own folder.
expect(cmd!.content).toContain("WindowsPowerShell\\v1.0\\powershell.exe");
expect(cmd!.content).toContain("-ExecutionPolicy Bypass");
expect(cmd!.content).toContain(`-File "%~dp0${ps1!.path}"`);
}
});
it("reads its config from the injected env and never embeds a key (both scripts)", () => {
for (const skill of PLUS_SKILLS) {
const sh = scriptOf(skill.name, ".sh");
expect(sh).toContain(`#!/bin/sh`);
expect(sh).toContain(PLUS_ENV.licenseKey);
expect(sh).toContain(PLUS_ENV.baseUrl);
// Auth flows through the env var, not a literal embedded key.
expect(sh).toContain("Authorization: Bearer $KEY");
expect(sh).toContain("X-Client-Version: $CLIENT_VERSION");
// Guard + soft fallback when the license/relay config is absent.
expect(sh).toContain('[ -n "$KEY" ] && [ -n "$BASE" ] || no_license');
expect(sh).toContain("Copilot Plus");
const ps1 = scriptOf(skill.name, ".ps1");
expect(ps1).toContain(`[Environment]::GetEnvironmentVariable('${PLUS_ENV.licenseKey}')`);
expect(ps1).toContain(`[Environment]::GetEnvironmentVariable('${PLUS_ENV.baseUrl}')`);
expect(ps1).toContain('Authorization = "Bearer $KEY"');
expect(ps1).toContain("'X-Client-Version' = $CLIENT_VERSION");
// Same license guard as the shell script.
expect(ps1).toContain("if (-not $KEY -or -not $BASE) { NoLicense }");
expect(ps1).toContain("Copilot Plus");
// The body is sent as explicit UTF-8 bytes — Windows PowerShell 5.1 would
// otherwise ASCII-encode a string body and corrupt non-ASCII input.
expect(ps1).toContain("[System.Text.Encoding]::UTF8.GetBytes($json)");
expect(ps1).toContain("application/json; charset=utf-8");
expect(ps1).toContain("-Body $bytes");
// Output side: force UTF-8 so non-ASCII relay output isn't mojibaked by
// Windows PowerShell 5.1's default code-page console encoding.
expect(ps1).toContain("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8");
expect(ps1).toContain("$OutputEncoding = [System.Text.Encoding]::UTF8");
}
});
it("falls back to the agent's own tools instead of blocking when Plus is absent", () => {
for (const skill of PLUS_SKILLS) {
const sh = scriptOf(skill.name, ".sh");
// No license: tell the agent to use its own equivalent tools, never
// refuse, and only append the upsell occasionally (gated on the pid). The
// fallback wording is generic (not web-specific) so it suits the PDF skill
// too, which shares this message.
expect(sh).toContain("your own equivalent built-in tools");
expect(sh).not.toContain("web tools");
expect(sh).toContain("never refuse");
expect(sh).toContain("$(( $$ % 4 ))");
// The upsell carries the actionable instruction to obtain a license key.
expect(sh).toContain("get a license key at https://www.obsidiancopilot.com");
// The invalid/expired-license (401/403) path is distinct and warrants a
// renewal note, but still falls back rather than refusing.
expect(sh).toContain('401|403) die "$LICENSE_INVALID"');
expect(sh).toContain("renew their Copilot Plus license");
// The old hard "requires Copilot Plus / upgrade" block is gone.
expect(sh).not.toContain("require Copilot Plus");
// A non-license relay failure (unreachable, or a non-2xx that isn't
// 401/403 — e.g. a page that can't be fetched) still routes the agent to
// its own tool rather than dead-ending the request.
expect(sh).toContain("$RELAY_FAILED_FALLBACK");
expect(sh).toContain("your own equivalent built-in tool for this");
const ps1 = scriptOf(skill.name, ".ps1");
expect(ps1).toContain("your own equivalent built-in tools");
expect(ps1).not.toContain("web tools");
expect(ps1).toContain("($PID % 4) -eq 0");
expect(ps1).toContain("Die $LICENSE_INVALID");
expect(ps1).toContain("RELAY_FAILED_FALLBACK");
}
});
it("includes the firecrawl-backed web-fetch skill targeting /url4llm", () => {
expect(scriptOf("copilot-web-fetch", ".sh")).toContain('relay "/url4llm"');
expect(scriptOf("copilot-web-fetch", ".sh")).toContain('\\"url\\"');
expect(scriptOf("copilot-web-fetch", ".ps1")).toContain('Invoke-Relay "/url4llm"');
expect(scriptOf("copilot-web-fetch", ".ps1")).toContain(
"@{ url = $ARG; user_id = $USER_ID }"
);
});
it("maps each relay tool to its endpoint and request body (both scripts)", () => {
expect(scriptOf("copilot-web-search", ".sh")).toContain('relay "/websearch"');
expect(scriptOf("copilot-web-search", ".sh")).toContain('\\"query\\"');
expect(scriptOf("copilot-youtube-transcript", ".sh")).toContain('relay "/youtube4llm"');
expect(scriptOf("copilot-fetch-x", ".sh")).toContain('relay "/twitter4llm"');
// Single-arg tools JSON-escape the argument they pass.
expect(scriptOf("copilot-web-search", ".sh")).toContain('$(json_escape "$ARG")');
// The PowerShell sibling hits the same endpoints with a structured body.
expect(scriptOf("copilot-web-search", ".ps1")).toContain('Invoke-Relay "/websearch"');
expect(scriptOf("copilot-web-search", ".ps1")).toContain(
"@{ query = $ARG; user_id = $USER_ID }"
);
expect(scriptOf("copilot-youtube-transcript", ".ps1")).toContain(
'Invoke-Relay "/youtube4llm"'
);
expect(scriptOf("copilot-fetch-x", ".ps1")).toContain('Invoke-Relay "/twitter4llm"');
});
it("read-pdf base64-encodes the file into the pdf field (both scripts)", () => {
const sh = scriptOf("copilot-read-pdf", ".sh");
expect(sh).toContain('relay "/pdf4llm"');
expect(sh).toContain("base64");
expect(sh).toContain('\\"pdf\\"');
const ps1 = scriptOf("copilot-read-pdf", ".ps1");
expect(ps1).toContain('Invoke-Relay "/pdf4llm"');
expect(ps1).toContain(
"[System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($FILE))"
);
expect(ps1).toContain("@{ pdf = $PDF; user_id = $USER_ID }");
});
});
it("keeps the SKILL.md frontmatter version in sync with the numeric version", () => {
for (const skill of BUILTIN_SKILLS) {
expect(skill.skillMd).toContain(`copilot-builtin-version: "${skill.version}"`);
}
describe("MIYO_SEARCH_SKILL", () => {
it("is a separate, Miyo-gated skill — not one of the always-seeded Plus skills", () => {
expect(BUILTIN_SKILLS.map((s) => s.name)).not.toContain("miyo-search");
expect(MIYO_SEARCH_SKILL.name).toBe("miyo-search");
expect(MIYO_SEARCH_SKILL.enabledAgents).toEqual(["claude", "codex", "opencode"]);
});
const miyoScript = (ext: ".sh" | ".cmd"): string => {
const file = MIYO_SEARCH_SKILL.files.find((f) => f.path.endsWith(ext));
if (!file) throw new Error(`miyo-search ships no ${ext} script`);
return file.content;
};
it("ships exactly two OS wrappers — POSIX sh + Windows cmd, no Node", () => {
expect(MIYO_SEARCH_SKILL.files.map((f) => f.path)).toEqual([
"miyo-search.sh",
"miyo-search.cmd",
]);
expect(MIYO_SEARCH_SKILL.skillMd).toContain(
`sh "/absolute/path/to/this/skill/directory/miyo-search.sh"`
);
// Windows is shown with the PowerShell call operator `&` (a bare quoted
// path is a string in PowerShell and wouldn't run).
expect(MIYO_SEARCH_SKILL.skillMd).toContain(
`& "/absolute/path/to/this/skill/directory/miyo-search.cmd"`
);
// No Node runtime anywhere — neither a .mjs file nor a node invocation.
expect(MIYO_SEARCH_SKILL.files.some((f) => f.path.endsWith(".mjs"))).toBe(false);
expect(MIYO_SEARCH_SKILL.skillMd).not.toContain("node ");
});
it("keeps the SKILL.md frontmatter version in sync with the numeric version", () => {
expect(MIYO_SEARCH_SKILL.skillMd).toContain(
`copilot-builtin-version: "${MIYO_SEARCH_SKILL.version}"`
);
});
it("embeds no Plus license env — Miyo is a local loopback CLI", () => {
expect(MIYO_SEARCH_SKILL.skillMd).not.toContain(PLUS_ENV.licenseKey);
expect(MIYO_SEARCH_SKILL.skillMd).not.toContain(PLUS_ENV.baseUrl);
expect(miyoScript(".sh")).not.toContain(PLUS_ENV.licenseKey);
expect(miyoScript(".cmd")).not.toContain(PLUS_ENV.licenseKey);
});
it("documents concrete triggers for when to call it", () => {
const md = MIYO_SEARCH_SKILL.skillMd;
// The description is the agent's primary "when to use" signal.
expect(md).toMatch(/description:[^\n]*too slow/i);
expect(md).toMatch(/description:[^\n]*explicitly asks for Miyo search/i);
// The body reinforces the same triggers.
expect(md).toMatch(/When to use it/);
expect(md).toMatch(/doesn't surface enough relevant notes/i);
});
it("runs one deterministic `miyo search ... --json` in each script", () => {
expect(miyoScript(".sh")).toContain('search "$QUERY"');
expect(miyoScript(".sh")).toContain("--json");
expect(miyoScript(".cmd")).toContain("search %* -n 10 --json");
});
it("resolves the binary absolute-path-first with a PATH fallback, per OS", () => {
// POSIX (.sh): absolute install path tried before falling back to PATH.
expect(miyoScript(".sh")).toContain("$HOME/.miyo/bin/miyo");
expect(miyoScript(".sh")).toContain("command -v miyo");
// Windows (.cmd): the %LOCALAPPDATA% install, then PATH.
expect(miyoScript(".cmd")).toContain("%LOCALAPPDATA%\\Miyo\\bin\\miyo\\miyo.exe");
expect(miyoScript(".cmd")).toContain("where miyo");
});
it("degrades clearly when Miyo is not installed (both scripts)", () => {
expect(miyoScript(".sh")).toMatch(/not installed/i);
expect(miyoScript(".sh")).toMatch(/may not be running/i);
expect(miyoScript(".cmd")).toMatch(/not installed/i);
});
});
it("ships one runnable script per OS — POSIX sh + Windows cmd/ps1, no Node", () => {
for (const skill of BUILTIN_SKILLS) {
const sh = skill.files.find((f) => f.path.endsWith(".sh"));
const cmd = skill.files.find((f) => f.path.endsWith(".cmd"));
const ps1 = skill.files.find((f) => f.path.endsWith(".ps1"));
expect(sh).toBeDefined();
expect(cmd).toBeDefined();
expect(ps1).toBeDefined();
// The three scripts share a base name (web-search.sh ↔ .cmd ↔ .ps1).
expect(cmd!.path).toBe(sh!.path.replace(/\.sh$/, ".cmd"));
expect(ps1!.path).toBe(sh!.path.replace(/\.sh$/, ".ps1"));
// SKILL.md routes macOS/Linux at sh and Windows at the cmd wrapper (run
// with PowerShell's `&` call operator), with no Node anywhere.
expect(skill.skillMd).toContain(`sh "/absolute/path/to/this/skill/directory/${sh!.path}"`);
expect(skill.skillMd).toContain(`& "/absolute/path/to/this/skill/directory/${cmd!.path}"`);
expect(skill.skillMd).not.toContain("install Node.js");
expect(skill.skillMd).not.toContain("node ");
// No Node runtime ships anymore.
expect(skill.files.some((f) => f.path.endsWith(".mjs"))).toBe(false);
// The cmd launcher drives the sibling ps1 via Windows PowerShell with the
// execution policy relaxed, locating it relative to its own folder.
expect(cmd!.content).toContain("WindowsPowerShell\\v1.0\\powershell.exe");
expect(cmd!.content).toContain("-ExecutionPolicy Bypass");
expect(cmd!.content).toContain(`-File "%~dp0${ps1!.path}"`);
}
});
describe("managedBuiltinSkills()", () => {
it("includes the Miyo skill only when Miyo is in use", () => {
expect(managedBuiltinSkills(true)).toContain(MIYO_SEARCH_SKILL);
expect(managedBuiltinSkills(false)).not.toContain(MIYO_SEARCH_SKILL);
});
it("reads its config from the injected env and never embeds a key (both scripts)", () => {
for (const skill of BUILTIN_SKILLS) {
const sh = scriptOf(skill.name, ".sh");
expect(sh).toContain(`#!/bin/sh`);
expect(sh).toContain(PLUS_ENV.licenseKey);
expect(sh).toContain(PLUS_ENV.baseUrl);
// Auth flows through the env var, not a literal embedded key.
expect(sh).toContain("Authorization: Bearer $KEY");
expect(sh).toContain("X-Client-Version: $CLIENT_VERSION");
// Guard + soft fallback when the license/relay config is absent.
expect(sh).toContain('[ -n "$KEY" ] && [ -n "$BASE" ] || no_license');
expect(sh).toContain("Copilot Plus");
it("appends Miyo after the Plus skills, preserving their order", () => {
expect(managedBuiltinSkills(true).map((s) => s.name)).toEqual([
...BUILTIN_SKILLS.map((s) => s.name),
"miyo-search",
]);
});
const ps1 = scriptOf(skill.name, ".ps1");
expect(ps1).toContain(`[Environment]::GetEnvironmentVariable('${PLUS_ENV.licenseKey}')`);
expect(ps1).toContain(`[Environment]::GetEnvironmentVariable('${PLUS_ENV.baseUrl}')`);
expect(ps1).toContain('Authorization = "Bearer $KEY"');
expect(ps1).toContain("'X-Client-Version' = $CLIENT_VERSION");
// Same license guard as the shell script.
expect(ps1).toContain("if (-not $KEY -or -not $BASE) { NoLicense }");
expect(ps1).toContain("Copilot Plus");
// The body is sent as explicit UTF-8 bytes — Windows PowerShell 5.1 would
// otherwise ASCII-encode a string body and corrupt non-ASCII input.
expect(ps1).toContain("[System.Text.Encoding]::UTF8.GetBytes($json)");
expect(ps1).toContain("application/json; charset=utf-8");
expect(ps1).toContain("-Body $bytes");
// Output side: force UTF-8 so non-ASCII relay output isn't mojibaked by
// Windows PowerShell 5.1's default code-page console encoding.
expect(ps1).toContain("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8");
expect(ps1).toContain("$OutputEncoding = [System.Text.Encoding]::UTF8");
}
});
it("falls back to the agent's own tools instead of blocking when Plus is absent", () => {
for (const skill of BUILTIN_SKILLS) {
const sh = scriptOf(skill.name, ".sh");
// No license: tell the agent to use its own equivalent tools, never
// refuse, and only append the upsell occasionally (gated on the pid). The
// fallback wording is generic (not web-specific) so it suits the PDF skill
// too, which shares this message.
expect(sh).toContain("your own equivalent built-in tools");
expect(sh).not.toContain("web tools");
expect(sh).toContain("never refuse");
expect(sh).toContain("$(( $$ % 4 ))");
// The upsell carries the actionable instruction to obtain a license key.
expect(sh).toContain("get a license key at https://www.obsidiancopilot.com");
// The invalid/expired-license (401/403) path is distinct and warrants a
// renewal note, but still falls back rather than refusing.
expect(sh).toContain('401|403) die "$LICENSE_INVALID"');
expect(sh).toContain("renew their Copilot Plus license");
// The old hard "requires Copilot Plus / upgrade" block is gone.
expect(sh).not.toContain("require Copilot Plus");
// A non-license relay failure (unreachable, or a non-2xx that isn't
// 401/403 — e.g. a page that can't be fetched) still routes the agent to
// its own tool rather than dead-ending the request.
expect(sh).toContain("$RELAY_FAILED_FALLBACK");
expect(sh).toContain("your own equivalent built-in tool for this");
const ps1 = scriptOf(skill.name, ".ps1");
expect(ps1).toContain("your own equivalent built-in tools");
expect(ps1).not.toContain("web tools");
expect(ps1).toContain("($PID % 4) -eq 0");
expect(ps1).toContain("Die $LICENSE_INVALID");
expect(ps1).toContain("RELAY_FAILED_FALLBACK");
}
});
it("includes the firecrawl-backed web-fetch skill targeting /url4llm", () => {
expect(scriptOf("copilot-web-fetch", ".sh")).toContain('relay "/url4llm"');
expect(scriptOf("copilot-web-fetch", ".sh")).toContain('\\"url\\"');
expect(scriptOf("copilot-web-fetch", ".ps1")).toContain('Invoke-Relay "/url4llm"');
expect(scriptOf("copilot-web-fetch", ".ps1")).toContain("@{ url = $ARG; user_id = $USER_ID }");
});
it("maps each relay tool to its endpoint and request body (both scripts)", () => {
expect(scriptOf("copilot-web-search", ".sh")).toContain('relay "/websearch"');
expect(scriptOf("copilot-web-search", ".sh")).toContain('\\"query\\"');
expect(scriptOf("copilot-youtube-transcript", ".sh")).toContain('relay "/youtube4llm"');
expect(scriptOf("copilot-fetch-x", ".sh")).toContain('relay "/twitter4llm"');
// Single-arg tools JSON-escape the argument they pass.
expect(scriptOf("copilot-web-search", ".sh")).toContain('$(json_escape "$ARG")');
// The PowerShell sibling hits the same endpoints with a structured body.
expect(scriptOf("copilot-web-search", ".ps1")).toContain('Invoke-Relay "/websearch"');
expect(scriptOf("copilot-web-search", ".ps1")).toContain(
"@{ query = $ARG; user_id = $USER_ID }"
);
expect(scriptOf("copilot-youtube-transcript", ".ps1")).toContain('Invoke-Relay "/youtube4llm"');
expect(scriptOf("copilot-fetch-x", ".ps1")).toContain('Invoke-Relay "/twitter4llm"');
});
it("read-pdf base64-encodes the file into the pdf field (both scripts)", () => {
const sh = scriptOf("copilot-read-pdf", ".sh");
expect(sh).toContain('relay "/pdf4llm"');
expect(sh).toContain("base64");
expect(sh).toContain('\\"pdf\\"');
const ps1 = scriptOf("copilot-read-pdf", ".ps1");
expect(ps1).toContain('Invoke-Relay "/pdf4llm"');
expect(ps1).toContain(
"[System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($FILE))"
);
expect(ps1).toContain("@{ pdf = $PDF; user_id = $USER_ID }");
});
});
describe("miyo-search builtin skill", () => {
it("is a separate, Miyo-gated skill — not one of the always-seeded Plus skills", () => {
expect(BUILTIN_SKILLS.map((s) => s.name)).not.toContain("miyo-search");
expect(MIYO_SEARCH_SKILL.name).toBe("miyo-search");
expect(MIYO_SEARCH_SKILL.enabledAgents).toEqual(["claude", "codex", "opencode"]);
});
const miyoScript = (ext: ".sh" | ".cmd"): string => {
const file = MIYO_SEARCH_SKILL.files.find((f) => f.path.endsWith(ext));
if (!file) throw new Error(`miyo-search ships no ${ext} script`);
return file.content;
};
it("ships exactly two OS wrappers — POSIX sh + Windows cmd, no Node", () => {
expect(MIYO_SEARCH_SKILL.files.map((f) => f.path)).toEqual([
"miyo-search.sh",
"miyo-search.cmd",
]);
expect(MIYO_SEARCH_SKILL.skillMd).toContain(
`sh "/absolute/path/to/this/skill/directory/miyo-search.sh"`
);
// Windows is shown with the PowerShell call operator `&` (a bare quoted
// path is a string in PowerShell and wouldn't run).
expect(MIYO_SEARCH_SKILL.skillMd).toContain(
`& "/absolute/path/to/this/skill/directory/miyo-search.cmd"`
);
// No Node runtime anywhere — neither a .mjs file nor a node invocation.
expect(MIYO_SEARCH_SKILL.files.some((f) => f.path.endsWith(".mjs"))).toBe(false);
expect(MIYO_SEARCH_SKILL.skillMd).not.toContain("node ");
});
it("keeps the SKILL.md frontmatter version in sync with the numeric version", () => {
expect(MIYO_SEARCH_SKILL.skillMd).toContain(
`copilot-builtin-version: "${MIYO_SEARCH_SKILL.version}"`
);
});
it("embeds no Plus license env — Miyo is a local loopback CLI", () => {
expect(MIYO_SEARCH_SKILL.skillMd).not.toContain(PLUS_ENV.licenseKey);
expect(MIYO_SEARCH_SKILL.skillMd).not.toContain(PLUS_ENV.baseUrl);
expect(miyoScript(".sh")).not.toContain(PLUS_ENV.licenseKey);
expect(miyoScript(".cmd")).not.toContain(PLUS_ENV.licenseKey);
});
it("documents concrete triggers for when to call it", () => {
const md = MIYO_SEARCH_SKILL.skillMd;
// The description is the agent's primary "when to use" signal.
expect(md).toMatch(/description:[^\n]*too slow/i);
expect(md).toMatch(/description:[^\n]*explicitly asks for Miyo search/i);
// The body reinforces the same triggers.
expect(md).toMatch(/When to use it/);
expect(md).toMatch(/doesn't surface enough relevant notes/i);
});
it("runs one deterministic `miyo search ... --json` in each script", () => {
expect(miyoScript(".sh")).toContain('search "$QUERY"');
expect(miyoScript(".sh")).toContain("--json");
expect(miyoScript(".cmd")).toContain("search %* -n 10 --json");
});
it("resolves the binary absolute-path-first with a PATH fallback, per OS", () => {
// POSIX (.sh): absolute install path tried before falling back to PATH.
expect(miyoScript(".sh")).toContain("$HOME/.miyo/bin/miyo");
expect(miyoScript(".sh")).toContain("command -v miyo");
// Windows (.cmd): the %LOCALAPPDATA% install, then PATH.
expect(miyoScript(".cmd")).toContain("%LOCALAPPDATA%\\Miyo\\bin\\miyo\\miyo.exe");
expect(miyoScript(".cmd")).toContain("where miyo");
});
it("degrades clearly when Miyo is not installed (both scripts)", () => {
expect(miyoScript(".sh")).toMatch(/not installed/i);
expect(miyoScript(".sh")).toMatch(/may not be running/i);
expect(miyoScript(".cmd")).toMatch(/not installed/i);
});
});
describe("managedBuiltinSkills", () => {
it("includes the Miyo skill only when Miyo is in use", () => {
expect(managedBuiltinSkills(true)).toContain(MIYO_SEARCH_SKILL);
expect(managedBuiltinSkills(false)).not.toContain(MIYO_SEARCH_SKILL);
});
it("appends Miyo after the Plus skills, preserving their order", () => {
expect(managedBuiltinSkills(true).map((s) => s.name)).toEqual([
...BUILTIN_SKILLS.map((s) => s.name),
"miyo-search",
]);
});
it("returns the stable BUILTIN_SKILLS reference when Miyo is off", () => {
expect(managedBuiltinSkills(false)).toBe(BUILTIN_SKILLS);
it("returns the stable BUILTIN_SKILLS reference when Miyo is off", () => {
expect(managedBuiltinSkills(false)).toBe(BUILTIN_SKILLS);
});
});
});

View file

@ -1,20 +1,19 @@
import type { BackendId } from "@/agentMode/session/types";
import { OBSIDIAN_SKILLS } from "./obsidianSkills";
/**
* Plugin-shipped ("builtin") Agent Mode skills that wrap Copilot Plus relay
* capabilities (web search, web fetch, PDF, YouTube, X). Unlike user-authored
* skills, these are seeded into the canonical skills folder by the plugin (see
* `seedBuiltinSkills`) and refreshed when `version` bumps.
* Plugin-shipped ("builtin") Agent Mode skills. Unlike user-authored skills,
* these are seeded into the canonical skills folder by the plugin (see
* `seedBuiltinSkills`) and refreshed when `version` bumps. A builtin may be
* executable (the Copilot Plus relay skills below) or knowledge-only (the
* Obsidian format and CLI skills).
*
* Each skill ships a `SKILL.md` (instructions the agent reads) plus one
* runnable script per OS: a POSIX `sh` script for macOS/Linux and a Windows
* `.cmd` wrapper that drives an adjacent PowerShell `.ps1`. All read the Copilot
* Plus license + relay base URL from env vars the plugin injects at spawn time
* (see `buildCopilotPlusEnv`) and call the Brevilabs relay directly no key is
* embedded in the skill files. When no license is configured (free user) or the
* relay rejects it, the script exits non-zero with a message that tells the
* agent to fall back to its own equivalent built-in capability never to block
* the user with only an occasional, gentle upsell.
* The Copilot Plus skills each ship a `SKILL.md` plus one runnable script per
* OS: a POSIX `sh` script for macOS/Linux and a Windows `.cmd` wrapper that
* drives an adjacent PowerShell `.ps1`. All read the Copilot Plus license +
* relay base URL from env vars the plugin injects at spawn time (see
* `buildCopilotPlusEnv`) and call the Brevilabs relay directly no key is
* embedded in the skill files.
*
* Why one script per OS (and no Node): every runtime here is guaranteed present
* without any install. On macOS/Linux the agent's shell has `sh`, `curl`, `sed`,
@ -27,10 +26,10 @@ import type { BackendId } from "@/agentMode/session/types";
* no extra imports and no executable bit.
*/
export interface BuiltinSkill {
/** Folder name + SKILL.md `name`. Kebab-case, Copilot-branded. */
/** Folder name + SKILL.md `name`. */
readonly name: string;
/**
* Bump when `skillMd` or any script changes so seeded copies refresh.
* Bump when `skillMd` or any support file changes so seeded copies refresh.
* Stamped into `metadata.copilot-builtin-version` in the seeded SKILL.md.
*/
readonly version: number;
@ -38,7 +37,7 @@ export interface BuiltinSkill {
readonly enabledAgents: readonly BackendId[];
/** Full SKILL.md file contents (frontmatter + body). */
readonly skillMd: string;
/** Supporting files written alongside SKILL.md (the runnable script). */
/** Supporting scripts, references, or notices written alongside SKILL.md. */
readonly files: ReadonlyArray<{ readonly path: string; readonly content: string }>;
}
@ -490,13 +489,14 @@ const FETCH_X = relaySkill({
scriptFile: "fetch-x.sh",
});
/** All plugin-shipped Copilot Plus relay skills, in display order. */
/** All always-seeded plugin-shipped skills, in display order. */
export const BUILTIN_SKILLS: readonly BuiltinSkill[] = [
WEB_SEARCH,
WEB_FETCH,
READ_PDF,
YOUTUBE_TRANSCRIPT,
FETCH_X,
...OBSIDIAN_SKILLS,
];
const MIYO_SEARCH_VERSION = 2;

View file

@ -0,0 +1,157 @@
import { OBSIDIAN_SKILLS, OBSIDIAN_SKILLS_UPSTREAM_REVISION } from "./obsidianSkills";
import { parseSkillFile } from "@/agentMode/skills/skillFormat";
function skillMd(name: string): string {
const skill = OBSIDIAN_SKILLS.find((candidate) => candidate.name === name);
if (!skill) throw new Error(`missing Obsidian skill: ${name}`);
return skill.skillMd;
}
describe("obsidianSkills", () => {
describe("OBSIDIAN_SKILLS", () => {
it("ships the four approved skills for every Agent Mode backend", () => {
expect(OBSIDIAN_SKILLS.map((skill) => skill.name)).toEqual([
"obsidian-markdown",
"obsidian-bases",
"json-canvas",
"obsidian-cli",
]);
for (const skill of OBSIDIAN_SKILLS) {
expect(skill.enabledAgents).toEqual(["claude", "codex", "opencode"]);
expect(skill.skillMd).toContain(`copilot-builtin-version: "${skill.version}"`);
}
});
it("parses each SKILL.md with the same validator used by discovery", () => {
for (const skill of OBSIDIAN_SKILLS) {
const parsed = parseSkillFile(skill.skillMd, skill.name);
expect(parsed.frontmatter.name).toBe(skill.name);
expect(parsed.frontmatter.enabledAgents).toEqual(["claude", "codex", "opencode"]);
}
});
it("pins and attributes the upstream MIT-licensed source", () => {
expect(OBSIDIAN_SKILLS_UPSTREAM_REVISION).toBe("a1dc48e68138490d522c04cbf5822214c6eb1202");
for (const skill of OBSIDIAN_SKILLS) {
expect(skill.skillMd).toContain(
`copilot-upstream-revision: "${OBSIDIAN_SKILLS_UPSTREAM_REVISION}"`
);
expect(skill.skillMd).toContain("license: MIT");
const license = skill.files.find((file) => file.path === "LICENSE");
expect(license?.content).toContain("Copyright (c) 2026 Steph Ango");
expect(license?.content).toContain("MIT License");
}
});
it("ships every relative reference linked from a SKILL.md", () => {
for (const skill of OBSIDIAN_SKILLS) {
const linkedPaths = [...skill.skillMd.matchAll(/\]\((references\/[^)]+)\)/g)].map(
(match) => match[1]
);
const shippedPaths = skill.files.map((file) => file.path);
for (const linkedPath of linkedPaths) {
expect(shippedPaths).toContain(linkedPath);
}
}
});
it("keeps the Markdown skill focused on Obsidian extensions", () => {
const md = skillMd("obsidian-markdown");
expect(md).toContain("Wikilinks and block references");
expect(md).toContain("![[document.pdf#page=3]]");
expect(md).toContain("> [!warning]");
expect(md).toContain("%%inline comments%%");
expect(md).not.toContain("## Math");
expect(md).not.toContain("## Diagrams");
expect(md).not.toContain("## Footnotes");
expect(OBSIDIAN_SKILLS.find((skill) => skill.name === "obsidian-markdown")?.files).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "references/CALLOUTS.md" }),
expect.objectContaining({ path: "references/EMBEDS.md" }),
expect.objectContaining({ path: "references/PROPERTIES.md" }),
])
);
});
it("preserves Bases schema, formula, quoting, and duration guidance", () => {
const md = skillMd("obsidian-bases");
expect(md).toContain("formulas:");
expect(md).toContain("views:");
expect(md).toContain("file.backlinks");
expect(md).toContain("Date subtraction returns a Duration");
expect(md).toContain("Wrap formulas containing double quotes in YAML single quotes");
expect(OBSIDIAN_SKILLS.find((skill) => skill.name === "obsidian-bases")?.files).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: "references/FUNCTIONS_REFERENCE.md" }),
expect.objectContaining({ path: "references/EXAMPLES.md" }),
])
);
});
it("preserves JSON Canvas schema, layout, color, and integrity guidance", () => {
const md = skillMd("json-canvas");
expect(md).toContain("16-character hexadecimal ID");
expect(md).toContain('"fromNode"');
expect(md).toContain('"toNode"');
expect(md).toContain("IDs are unique across nodes and edges");
expect(md).toContain('"1"');
expect(OBSIDIAN_SKILLS.find((skill) => skill.name === "json-canvas")?.files).toEqual(
expect.arrayContaining([expect.objectContaining({ path: "references/EXAMPLES.md" })])
);
});
it("uses the CLI only for Obsidian runtime, index, and developer capabilities", () => {
const md = skillMd("obsidian-cli");
expect(md).toMatch(/description:[^\n]*currently open notes and tabs/);
expect(md).toContain("obsidian version");
expect(md).toContain("probe must exit\nsuccessfully");
expect(md).toContain("obsidian help <command>");
expect(md).toContain("Settings → General → Command line\ninterface");
expect(md).toContain('obsidian vault="My Vault"');
expect(md).toContain("property:set");
expect(md).toContain("base:query");
expect(md).toContain("template:read ... resolve");
expect(md).toContain('obsidian vault="My Vault" tabs ids');
expect(md).toContain('obsidian vault="My Vault" workspace ids');
expect(md).toContain("Keep entries\nverbatim");
expect(md).toContain("a Markdown note has an explicit vault path ending");
expect(md).toContain("another file-backed tab has an explicit vault path");
expect(md).toContain("a non-file view, such as search, graph, settings, or a plugin view");
expect(md).toContain("Do not infer a path from a display title, view type, or tab ID");
expect(md).toContain("do not\ndiscard entries that cannot be classified");
expect(md).toContain("app.workspace.iterateAllLeaves");
expect(md).toContain("app.workspace.getMostRecentLeaf()");
expect(md).toContain("leaf.getDisplayText()");
expect(md).toContain("leaf.view.getViewType()");
expect(md).toContain('endsWith(".md")');
expect(md).toContain("Only call an entry\nan open tab when its ID appears");
expect(md).toContain("sidebar and floating leaves");
expect(md).toContain("Preserve tab entries that have no\nmatching workspace entry");
expect(md).toContain("getMostRecentLeaf()?.view.file?.path");
expect(md).toContain("substitute <code>recents</code>");
expect(md).toContain(
"Use normal filesystem tools only for explicit paths returned by Obsidian"
);
expect(md).toContain("commands filter=");
expect(md).toContain("plugin:reload");
expect(md).toContain("dev:errors");
expect(md).toContain("dev:screenshot");
expect(md).toContain("dev:dom");
expect(md).toContain("dev:css");
expect(md).toContain("dev:mobile");
expect(md).toContain("Risky operations require explicit intent");
expect(md).toContain("permanent deletion");
expect(md).toContain("mutating JavaScript evaluation or CDP calls");
expect(md).toContain("Use normal shell\nfilesystem tools");
expect(md).not.toContain("obsidian read file=");
expect(md).not.toContain("obsidian create name=");
expect(md).not.toContain("obsidian search query=");
expect(md).not.toContain(" silent ");
});
it("does not include the deferred Defuddle or capture skills", () => {
expect(OBSIDIAN_SKILLS.map((skill) => skill.name)).not.toContain("defuddle");
expect(OBSIDIAN_SKILLS.some((skill) => skill.name.includes("capture"))).toBe(false);
});
});
});

View file

@ -0,0 +1,827 @@
import type { BuiltinSkill } from "./builtinSkills";
/** Pinned source revision for the adapted Obsidian skills. */
export const OBSIDIAN_SKILLS_UPSTREAM_REVISION = "a1dc48e68138490d522c04cbf5822214c6eb1202";
const ENABLED_AGENTS = ["claude", "codex", "opencode"] as const;
const VERSION = 1;
const UPSTREAM_LICENSE = String.raw`MIT License
Copyright (c) 2026 Steph Ango (@kepano)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
`;
const OBSIDIAN_MARKDOWN_SKILL_MD = String.raw`---
name: obsidian-markdown
description: Create and edit Obsidian-specific Markdown syntax, including wikilinks, embeds, block references, callouts, properties, tags, and comments. Use for Obsidian notes when these extensions matter; ordinary Markdown is assumed knowledge.
license: MIT
metadata:
copilot-enabled-agents: claude, codex, opencode
copilot-builtin-version: "${VERSION}"
copilot-upstream-revision: "${OBSIDIAN_SKILLS_UPSTREAM_REVISION}"
---
# Obsidian Markdown
Use Obsidian-specific syntax accurately. Do not spend tokens explaining ordinary
CommonMark or GFM unless the user asks.
## Workflow
1. Preserve existing frontmatter keys and formatting when editing a note.
2. Use wikilinks for vault notes and Markdown links for external URLs.
3. Use embeds, callouts, properties, tags, comments, and block references only
when they improve the requested note.
4. Check link targets, YAML validity, and block IDs after editing.
5. Read the focused reference file when the task needs more syntax detail.
## Wikilinks and block references
~~~markdown
[[Note Name]]
[[Note Name|Display Text]]
[[Note Name#Heading]]
[[Note Name#^block-id]]
[[#Heading in this note]]
This paragraph is addressable. ^block-id
~~~
Put a block ID on its own line after a list or quote block.
## Embeds
Prefix a wikilink with <code>!</code>:
~~~markdown
![[Note Name]]
![[Note Name#Heading]]
![[image.png|300]]
![[document.pdf#page=3]]
~~~
See [Embeds](references/EMBEDS.md) for media, PDF, and query forms.
## Callouts
~~~markdown
> [!warning] Custom title
> Important content.
> [!faq]- Collapsed by default
> Foldable content.
~~~
See [Callouts](references/CALLOUTS.md) for types, aliases, folding, and nesting.
## Properties, tags, and comments
~~~yaml
---
title: My Note
date: 2026-07-21
tags:
- project
aliases:
- Alternate Name
related: "[[Other Note]]"
---
~~~
Quote wikilinks used as YAML values. See
[Properties](references/PROPERTIES.md) for supported property types and tag rules.
Use <code>#nested/tag</code> for inline tags. Hide content from reading view with
<code>%%inline comments%%</code> or a matching pair of <code>%%</code> markers on
separate lines.
## Attribution
Adapted from <code>kepano/obsidian-skills</code> at revision
<code>${OBSIDIAN_SKILLS_UPSTREAM_REVISION}</code>. See <code>LICENSE</code>.
`;
const MARKDOWN_CALLOUTS_REFERENCE = String.raw`# Callouts reference
## Folding and nesting
~~~markdown
> [!faq]- Collapsed by default
> Hidden until expanded.
> [!faq]+ Expanded by default
> Visible but collapsible.
> [!question] Outer
> > [!note] Inner
> > Nested content.
~~~
## Built-in types
| Type | Aliases |
| --- | --- |
| note | |
| abstract | summary, tldr |
| info | |
| todo | |
| tip | hint, important |
| success | check, done |
| question | help, faq |
| warning | caution, attention |
| failure | fail, missing |
| danger | error |
| bug | |
| example | |
| quote | cite |
`;
const MARKDOWN_EMBEDS_REFERENCE = String.raw`# Embeds reference
~~~markdown
![[Note Name]]
![[Note Name#Heading]]
![[Note Name#^block-id]]
![[image.png]]
![[image.png|640x480]]
![[image.png|300]]
![[audio.mp3]]
![[video.mp4]]
![[document.pdf]]
![[document.pdf#page=3]]
![[document.pdf#height=400]]
~~~
A list embed needs a block ID after the list. An embedded search uses an
Obsidian query block:
~~~~markdown
~~~query
tag:#project status:done
~~~
~~~~
`;
const MARKDOWN_PROPERTIES_REFERENCE = String.raw`# Properties reference
Properties are YAML frontmatter at the very start of a note.
| Property type | Example |
| --- | --- |
| Text | <code>title: My title</code> |
| Number | <code>rating: 4.5</code> |
| Checkbox | <code>completed: true</code> |
| Date | <code>date: 2026-07-21</code> |
| Date and time | <code>due: 2026-07-21T14:30:00</code> |
| List | <code>tags: [one, two]</code> or a YAML list |
| Link | <code>related: "[[Other Note]]"</code> |
Obsidian reserves <code>tags</code>, <code>aliases</code>, and
<code>cssclasses</code> for their built-in behaviors. Tags may contain letters,
numbers (not as the first character), underscores, hyphens, and forward slashes.
Prefer YAML lists when a property naturally has multiple values.
`;
const OBSIDIAN_BASES_SKILL_MD = String.raw`---
name: obsidian-bases
description: Create and edit Obsidian Bases (.base files) with valid YAML schemas, filters, formulas, properties, summaries, and views. Use for database-like Obsidian views or when the user mentions Bases, .base files, table/card/list views, filters, formulas, or summaries.
license: MIT
metadata:
copilot-enabled-agents: claude, codex, opencode
copilot-builtin-version: "${VERSION}"
copilot-upstream-revision: "${OBSIDIAN_SKILLS_UPSTREAM_REVISION}"
---
# Obsidian Bases
## Workflow
1. Read the existing <code>.base</code> file before editing it.
2. Define global scope with <code>filters</code>.
3. Add computed values under <code>formulas</code> only when needed.
4. Configure property display names and one or more views.
5. Validate YAML, formula references, quoting, and date/duration operations.
6. Open or query the Base in Obsidian when the CLI is available.
## Schema
~~~yaml
filters:
and:
- 'file.ext == "md"'
- 'status != "archived"'
formulas:
days_until_due: 'if(due, (date(due) - today()).days, "")'
properties:
status:
displayName: Status
formula.days_until_due:
displayName: "Days Until Due"
summaries:
mean_rounded: 'values.mean().round(2)'
views:
- type: table
name: Active
limit: 50
filters:
and:
- 'status != "done"'
order:
- file.name
- status
- formula.days_until_due
groupBy:
property: status
direction: ASC
summaries:
formula.days_until_due: Average
~~~
Views may be <code>table</code>, <code>cards</code>, or <code>list</code>.
A <code>map</code> view depends on compatible map support.
## Filters and properties
Filters may be a single expression or recursively nested <code>and</code>,
<code>or</code>, and <code>not</code> objects. Property namespaces are:
- note properties: <code>status</code> or <code>note.status</code>
- file metadata: <code>file.name</code>, <code>file.path</code>,
<code>file.folder</code>, <code>file.ctime</code>, <code>file.mtime</code>,
<code>file.tags</code>, <code>file.links</code>, and
<code>file.backlinks</code>
- formulas: <code>formula.days_until_due</code>
Use <code>file.hasTag()</code>, <code>file.hasLink()</code>,
<code>file.hasProperty()</code>, and <code>file.inFolder()</code> for indexed
file relationships. The <code>this</code> value refers to the Base itself, the
embedding note, or the active file depending on where the Base is rendered.
## Formula rules
- Guard optional properties with <code>if()</code>.
- Date subtraction returns a Duration, not a number. Access
<code>.days</code>, <code>.hours</code>, or another numeric field before
calling number methods such as <code>.round()</code>.
- Every <code>formula.X</code> used by a view or property configuration must
have a matching <code>X</code> entry under <code>formulas</code>.
- Wrap formulas containing double quotes in YAML single quotes.
- Quote YAML strings containing special characters, especially colons and
leading punctuation.
Read [Functions reference](references/FUNCTIONS_REFERENCE.md) for function and
type-specific operations, and [Examples](references/EXAMPLES.md) for complete
task-tracker and daily-notes Bases.
## Validation checklist
- The document parses as YAML and has a <code>views</code> list.
- View <code>order</code>, <code>groupBy</code>, and summaries reference defined
note, file, or formula properties.
- Formula quoting is balanced and duration math accesses a numeric field.
- Embedded view names match exactly: <code>![[My Base.base#View Name]]</code>.
## Attribution
Adapted from <code>kepano/obsidian-skills</code> at revision
<code>${OBSIDIAN_SKILLS_UPSTREAM_REVISION}</code>. See <code>LICENSE</code>.
`;
const BASES_FUNCTIONS_REFERENCE = String.raw`# Bases functions reference
## Global functions
| Function | Purpose |
| --- | --- |
| <code>date(value)</code> | Parse a date string |
| <code>duration(value)</code> | Parse a duration string |
| <code>now()</code> / <code>today()</code> | Current date-time / date |
| <code>if(condition, yes, no?)</code> | Conditional value |
| <code>number(value)</code> | Convert to number |
| <code>link(path, display?)</code> | Create a link |
| <code>file(path)</code> | Resolve a file object |
| <code>list(value)</code> | Normalize a value to a list |
| <code>image(path)</code> / <code>icon(name)</code> | Create renderable values |
## Common methods
- String: <code>contains</code>, <code>startsWith</code>, <code>endsWith</code>,
<code>lower</code>, <code>trim</code>, <code>replace</code>,
<code>split</code>, <code>isEmpty</code>.
- Number: <code>abs</code>, <code>ceil</code>, <code>floor</code>,
<code>round</code>, <code>toFixed</code>.
- List: <code>contains</code>, <code>containsAll</code>,
<code>containsAny</code>, <code>filter</code>, <code>map</code>,
<code>reduce</code>, <code>flat</code>, <code>join</code>,
<code>sort</code>, <code>unique</code>.
- File: <code>asLink</code>, <code>hasLink</code>, <code>hasTag</code>,
<code>hasProperty</code>, <code>inFolder</code>.
- Link: <code>asFile</code>, <code>linksTo</code>.
- Object: <code>keys</code>, <code>values</code>, <code>isEmpty</code>.
- Regular expression: <code>matches</code>.
Date fields include <code>year</code>, <code>month</code>, <code>day</code>,
<code>hour</code>, <code>minute</code>, and <code>second</code>. Date methods
include <code>date()</code>, <code>format()</code>, <code>time()</code>, and
<code>relative()</code>.
Duration fields are <code>days</code>, <code>hours</code>,
<code>minutes</code>, <code>seconds</code>, and <code>milliseconds</code>.
Duration does not directly support number rounding methods.
`;
const BASES_EXAMPLES = String.raw`# Bases examples
## Task tracker
~~~yaml
filters:
and:
- file.hasTag("task")
- 'file.ext == "md"'
formulas:
days_until_due: 'if(due, (date(due) - today()).days, "")'
is_overdue: 'if(due, date(due) < today() && status != "done", false)'
properties:
formula.days_until_due:
displayName: "Days Until Due"
views:
- type: table
name: Active
filters:
and:
- 'status != "done"'
order:
- file.name
- status
- due
- formula.days_until_due
groupBy:
property: status
direction: ASC
~~~
## Daily notes index
~~~yaml
filters:
and:
- file.inFolder("Daily Notes")
- '/^\d{4}-\d{2}-\d{2}$/.matches(file.basename)'
formulas:
day_of_week: 'date(file.basename).format("dddd")'
word_estimate: '(file.size / 5).round(0)'
views:
- type: table
name: Recent notes
limit: 30
order:
- file.name
- formula.day_of_week
- formula.word_estimate
- file.mtime
~~~
`;
const JSON_CANVAS_SKILL_MD = String.raw`---
name: json-canvas
description: Create and edit JSON Canvas (.canvas) files with valid nodes, edges, groups, colors, layout, IDs, and referential integrity. Use for Obsidian Canvas files, visual maps, flowcharts, project boards, or any request involving the JSON Canvas format.
license: MIT
metadata:
copilot-enabled-agents: claude, codex, opencode
copilot-builtin-version: "${VERSION}"
copilot-upstream-revision: "${OBSIDIAN_SKILLS_UPSTREAM_REVISION}"
---
# JSON Canvas
Follow JSON Canvas 1.0. A <code>.canvas</code> document contains top-level
<code>nodes</code> and <code>edges</code> arrays.
## Workflow
1. Parse the existing JSON before editing it.
2. Generate a unique lowercase 16-character hexadecimal ID for each new node
or edge.
3. Position nodes without overlap and preserve intentional existing layout.
4. Point every edge at existing node IDs.
5. Serialize valid JSON and run the validation checklist below.
## Nodes
Every node requires <code>id</code>, <code>type</code>, <code>x</code>,
<code>y</code>, <code>width</code>, and <code>height</code>.
| Type | Required content | Purpose |
| --- | --- | --- |
| <code>text</code> | <code>text</code> | Markdown content |
| <code>file</code> | <code>file</code> | Vault file; optional <code>subpath</code> |
| <code>link</code> | <code>url</code> | External URL |
| <code>group</code> | none | Visual container; optional label/background |
Array order controls z-index: earlier nodes are behind later nodes. Coordinates
may be negative. Position is the top-left corner, x increases right, and y
increases down.
~~~json
{
"id": "6f0ad84f44ce9c17",
"type": "text",
"x": 0,
"y": 0,
"width": 360,
"height": 180,
"text": "# Main idea\n\nDetails",
"color": "5"
}
~~~
Use actual JSON newline escapes in text values. Do not double-escape them into
literal backslash-n text.
## Edges
Every edge requires <code>id</code>, <code>fromNode</code>, and
<code>toNode</code>. Optional sides are <code>top</code>, <code>right</code>,
<code>bottom</code>, or <code>left</code>. Optional ends are <code>none</code>
or <code>arrow</code>.
~~~json
{
"id": "0123456789abcdef",
"fromNode": "6f0ad84f44ce9c17",
"fromSide": "right",
"toNode": "a1b2c3d4e5f67890",
"toSide": "left",
"toEnd": "arrow",
"label": "leads to"
}
~~~
## Colors and layout
A color is a hex string or preset <code>"1"</code> through
<code>"6"</code>. Presets deliberately do not define exact hex colors. Leave
50100 px between nodes, 2050 px padding inside groups, and align to a simple
grid when creating a new layout.
## Validation checklist
- JSON parses successfully.
- IDs are unique across nodes and edges.
- Every <code>fromNode</code> and <code>toNode</code> exists in
<code>nodes</code>.
- Each node type has its required content field.
- Sides, ends, and colors use allowed values.
- Nodes do not unintentionally overlap and group children sit inside bounds.
Read [Examples](references/EXAMPLES.md) for complete connected and grouped
canvases.
## Attribution
Adapted from <code>kepano/obsidian-skills</code> at revision
<code>${OBSIDIAN_SKILLS_UPSTREAM_REVISION}</code>. See <code>LICENSE</code>.
`;
const JSON_CANVAS_EXAMPLES = String.raw`# JSON Canvas examples
## Connected notes
~~~json
{
"nodes": [
{
"id": "8a9b0c1d2e3f4a5b",
"type": "text",
"x": 0,
"y": 0,
"width": 300,
"height": 140,
"text": "# Main idea"
},
{
"id": "1a2b3c4d5e6f7a8b",
"type": "file",
"x": 400,
"y": 0,
"width": 300,
"height": 200,
"file": "Notes/Supporting note.md",
"subpath": "#Evidence"
}
],
"edges": [
{
"id": "3c4d5e6f7a8b9c0d",
"fromNode": "8a9b0c1d2e3f4a5b",
"fromSide": "right",
"toNode": "1a2b3c4d5e6f7a8b",
"toSide": "left",
"label": "supported by"
}
]
}
~~~
## Grouped board
~~~json
{
"nodes": [
{
"id": "5e6f7a8b9c0d1e2f",
"type": "group",
"x": 0,
"y": 0,
"width": 320,
"height": 500,
"label": "In progress",
"color": "3"
},
{
"id": "8b9c0d1e2f3a4b5c",
"type": "text",
"x": 30,
"y": 60,
"width": 260,
"height": 100,
"text": "## Task\n\nImplement the feature"
}
],
"edges": []
}
~~~
`;
const OBSIDIAN_CLI_SKILL_MD = String.raw`---
name: obsidian-cli
description: Use the official Obsidian CLI when a task needs Obsidian's running app, index, configured features, command registry, or developer runtime. Use for currently open notes and tabs, workspace state, daily notes, typed properties, tasks, links/backlinks, Bases queries, template resolution, link-aware moves, plugin commands, and plugin/theme debugging; do not use it for ordinary filesystem operations.
license: MIT
metadata:
copilot-enabled-agents: claude, codex, opencode
copilot-builtin-version: "${VERSION}"
copilot-upstream-revision: "${OBSIDIAN_SKILLS_UPSTREAM_REVISION}"
---
# Obsidian CLI
Use the CLI only for behavior that depends on Obsidian's running application,
indexes, settings, command registry, or developer runtime. Use normal shell
filesystem tools for ordinary file reads, writes, directory listing, and text
search.
## Capability probe and fallback
Before relying on the CLI, run:
~~~bash
obsidian version
~~~
A command being present on PATH is not sufficient: the probe must exit
successfully. If it fails, continue with ordinary filesystem tools where they
can satisfy the request. Briefly tell the user only when the missing runtime
capability matters. Do not install Obsidian, change PATH, register the CLI, or
raise the plugin's minimum Obsidian version on the user's behalf.
The CLI requires a compatible Obsidian installer and a running app. Commands
can differ by version, so inspect live help before using a command whose syntax
is not already established:
~~~bash
obsidian help <command>
~~~
When a request truly needs the runtime capability and the probe fails, tell the
user to open Obsidian and enable **Settings General Command line
interface** using a compatible installer. Leave registration and any platform
repair steps to the user.
## Target precisely
Put <code>vault=&lt;name-or-id&gt;</code> before the command whenever the vault is
known. Use <code>path=</code> for an exact vault-relative path. Use
<code>file=</code> only when Obsidian's wikilink-style name resolution is
desired. Do not rely on the active vault or active file when a precise target
is available.
~~~bash
obsidian vault="My Vault" backlinks path="Projects/Plan.md" format=json
~~~
Parameters use <code>name=value</code>; boolean flags have no value. Quote
values containing spaces or shell-special characters.
## High-value indexed and configured operations
Use live help for exact parameters, then prefer these families when they add
meaning beyond raw files:
- Configured daily notes: <code>daily</code>, <code>daily:path</code>,
<code>daily:read</code>, <code>daily:append</code>,
<code>daily:prepend</code>.
- Typed properties and parsed metadata: <code>properties</code>,
<code>property:read</code>, <code>property:set</code>,
<code>property:remove</code>, <code>tags</code>, <code>tag</code>, and
<code>aliases</code>. Supply a <code>type=</code> to
<code>property:set</code> when the property is not plain text.
- Tasks: <code>tasks</code> for indexed listing and <code>task</code> with a
stable <code>ref=path:line</code> or exact file/line for status changes.
- Link graph: <code>backlinks</code>, <code>links</code>,
<code>unresolved</code>, <code>orphans</code>, and <code>deadends</code>.
- Bases: <code>bases</code>, <code>base:views</code>, and
<code>base:query</code>. Prefer <code>format=json</code> for structured agent
consumption.
- Templates: <code>templates</code> and <code>template:read ... resolve</code>
when configured template resolution is required.
- Live workspace state: <code>tabs ids</code> lists the currently open tabs and
their IDs, while <code>workspace ids</code> shows the workspace tree and its
item IDs.
- Link-aware refactors: <code>move</code> and <code>rename</code> when the vault
setting to update internal links should be honored.
### Inspect open notes
When the user asks about notes currently open in Obsidian:
~~~bash
obsidian vault="My Vault" tabs ids
obsidian vault="My Vault" workspace ids
~~~
Use <code>tabs ids</code> as the source of truth for open tabs. Keep entries
verbatim and classify them only when the output provides enough evidence:
- a Markdown note has an explicit vault path ending in <code>.md</code>
(case-insensitive)
- another file-backed tab has an explicit vault path with a different extension
- a non-file view, such as search, graph, settings, or a plugin view, has no
vault path
Do not infer a path from a display title, view type, or tab ID, and do not
discard entries that cannot be classified. For a request about open notes,
extract the Markdown paths while retaining the other tabs as workspace context.
Use <code>workspace ids</code> when tab groups or workspace hierarchy matter. Do
not substitute <code>recents</code>, which includes files that are no longer open.
If the tab output does not expose paths or view types clearly, correlate its tab
IDs with this read-only, structured workspace query:
~~~bash
obsidian vault="My Vault" eval code='JSON.stringify((()=>{const tabs=[];const active=app.workspace.getMostRecentLeaf();app.workspace.iterateAllLeaves(leaf=>{const path=leaf.view.file?.path??null;tabs.push({id:leaf.id,title:leaf.getDisplayText(),viewType:leaf.view.getViewType(),path,kind:path===null?"view":path.toLowerCase().endsWith(".md")?"markdown":"file",active:leaf===active})});return tabs})())'
~~~
The workspace query also returns sidebar and floating leaves. Only call an entry
an open tab when its ID appears in <code>tabs ids</code>; retain query-only
entries separately as workspace context. Preserve tab entries that have no
matching workspace entry instead of guessing their identity.
If the user asks for the single currently focused note and the tab output does
not identify it, use a read-only app query:
~~~bash
obsidian vault="My Vault" eval code="app.workspace.getMostRecentLeaf()?.view.file?.path ?? ''"
~~~
Use normal filesystem tools only for explicit paths returned by Obsidian, and
choose a reader appropriate to the file type. Do not read every open note when
paths or titles alone answer the request.
## Obsidian and plugin commands
<code>commands</code> lists registered command IDs, including commands provided
by plugins. Filter by an ID prefix, then execute the selected command with
<code>command id=&lt;command-id&gt;</code>. Never guess a command ID when it can be
discovered.
~~~bash
obsidian vault="My Vault" commands filter="my-plugin:"
obsidian vault="My Vault" command id="my-plugin:run-action"
~~~
## Plugin and theme development
Use the CLI as the first choice for runtime verification after the normal build
or test command has produced artifacts:
1. Reload with <code>plugin:reload id=&lt;plugin-id&gt;</code>.
2. Inspect <code>dev:errors</code> and <code>dev:console level=error</code>.
3. Verify UI state with <code>dev:screenshot path=...</code>,
<code>dev:dom selector=...</code>, and <code>dev:css selector=...</code>.
4. Use <code>dev:mobile on</code> only when mobile emulation is relevant, and
turn it off afterward.
Read-only <code>eval</code> and <code>dev:cdp</code> queries are appropriate for
state that the documented inspection commands cannot expose. Keep expressions
small and return serializable values. Treat any expression or CDP call that
mutates application state as a risky operation requiring explicit user intent.
## Risky operations require explicit intent
Do not perform the following merely because they are available:
- permanent deletion
- local-history or Sync restoration
- publishing or unpublishing
- plugin or theme installation/uninstallation
- changing restricted mode
- restarting Obsidian
- mutating JavaScript evaluation or CDP calls
Confirm that the user's request clearly authorizes the exact target and effect.
Prefer reversible variants, such as trash-backed deletion, when they satisfy
the request.
## Exclusions
Do not teach or use the TUI, clipboard output, undocumented flags, platform
registration repairs, or CLI equivalents of generic filesystem operations in
this skill.
## Attribution
Adapted from <code>kepano/obsidian-skills</code> at revision
<code>${OBSIDIAN_SKILLS_UPSTREAM_REVISION}</code>. See <code>LICENSE</code>.
`;
const LICENSE_FILE = { path: "LICENSE", content: UPSTREAM_LICENSE } as const;
const OBSIDIAN_MARKDOWN: BuiltinSkill = {
name: "obsidian-markdown",
version: VERSION,
enabledAgents: ENABLED_AGENTS,
skillMd: OBSIDIAN_MARKDOWN_SKILL_MD,
files: [
{ path: "references/CALLOUTS.md", content: MARKDOWN_CALLOUTS_REFERENCE },
{ path: "references/EMBEDS.md", content: MARKDOWN_EMBEDS_REFERENCE },
{ path: "references/PROPERTIES.md", content: MARKDOWN_PROPERTIES_REFERENCE },
LICENSE_FILE,
],
};
const OBSIDIAN_BASES: BuiltinSkill = {
name: "obsidian-bases",
version: VERSION,
enabledAgents: ENABLED_AGENTS,
skillMd: OBSIDIAN_BASES_SKILL_MD,
files: [
{ path: "references/FUNCTIONS_REFERENCE.md", content: BASES_FUNCTIONS_REFERENCE },
{ path: "references/EXAMPLES.md", content: BASES_EXAMPLES },
LICENSE_FILE,
],
};
const JSON_CANVAS: BuiltinSkill = {
name: "json-canvas",
version: VERSION,
enabledAgents: ENABLED_AGENTS,
skillMd: JSON_CANVAS_SKILL_MD,
files: [{ path: "references/EXAMPLES.md", content: JSON_CANVAS_EXAMPLES }, LICENSE_FILE],
};
const OBSIDIAN_CLI: BuiltinSkill = {
name: "obsidian-cli",
version: VERSION,
enabledAgents: ENABLED_AGENTS,
skillMd: OBSIDIAN_CLI_SKILL_MD,
files: [LICENSE_FILE],
};
/** Obsidian-native skills seeded for every supported Agent Mode backend. */
export const OBSIDIAN_SKILLS: readonly BuiltinSkill[] = [
OBSIDIAN_MARKDOWN,
OBSIDIAN_BASES,
JSON_CANVAS,
OBSIDIAN_CLI,
];

View file

@ -57,172 +57,189 @@ const MD = "copilot/skills/copilot-web-search/SKILL.md";
const SCRIPT = "copilot/skills/copilot-web-search/web-search.sh";
describe("seedBuiltinSkills", () => {
it("writes SKILL.md and scripts when the skill is missing", async () => {
const fs = memFs();
const { seeded } = await seedBuiltinSkills({
skillsFolderRelPath: FOLDER,
fs,
skills: [skill(1)],
describe("seedBuiltinSkills()", () => {
it("writes SKILL.md and scripts when the skill is missing", async () => {
const fs = memFs();
const { seeded } = await seedBuiltinSkills({
skillsFolderRelPath: FOLDER,
fs,
skills: [skill(1)],
});
expect(seeded).toEqual(["copilot-web-search"]);
expect(fs.files.get(MD)).toContain("body v1");
expect(fs.files.get(SCRIPT)).toBe("// script v1");
expect(fs.dirs.has("copilot/skills/copilot-web-search")).toBe(true);
});
expect(seeded).toEqual(["copilot-web-search"]);
expect(fs.files.get(MD)).toContain("body v1");
expect(fs.files.get(SCRIPT)).toBe("// script v1");
expect(fs.dirs.has("copilot/skills/copilot-web-search")).toBe(true);
});
it("is idempotent: skips a skill already present at the current version", async () => {
const fs = memFs({ [MD]: skill(1).skillMd, [SCRIPT]: "// user-touched" });
const { seeded } = await seedBuiltinSkills({
skillsFolderRelPath: FOLDER,
fs,
skills: [skill(1)],
});
it("is idempotent: skips a skill already present at the current version", async () => {
const fs = memFs({ [MD]: skill(1).skillMd, [SCRIPT]: "// user-touched" });
const { seeded } = await seedBuiltinSkills({
skillsFolderRelPath: FOLDER,
fs,
skills: [skill(1)],
expect(seeded).toEqual([]);
// Untouched — the script the user may have inspected stays as-is.
expect(fs.files.get(SCRIPT)).toBe("// user-touched");
});
expect(seeded).toEqual([]);
// Untouched — the script the user may have inspected stays as-is.
expect(fs.files.get(SCRIPT)).toBe("// user-touched");
});
it("re-seeds when the bundled version is newer", async () => {
const fs = memFs({ [MD]: skill(1).skillMd, [SCRIPT]: "// script v1" });
const { seeded } = await seedBuiltinSkills({
skillsFolderRelPath: FOLDER,
fs,
skills: [skill(2)],
});
it("re-seeds when the bundled version is newer", async () => {
const fs = memFs({ [MD]: skill(1).skillMd, [SCRIPT]: "// script v1" });
const { seeded } = await seedBuiltinSkills({
skillsFolderRelPath: FOLDER,
fs,
skills: [skill(2)],
expect(seeded).toEqual(["copilot-web-search"]);
expect(fs.files.get(MD)).toContain("body v2");
expect(fs.files.get(SCRIPT)).toBe("// script v2");
});
expect(seeded).toEqual(["copilot-web-search"]);
expect(fs.files.get(MD)).toContain("body v2");
expect(fs.files.get(SCRIPT)).toBe("// script v2");
});
it("re-seeds when the SKILL.md was deleted", async () => {
// Script lingered but SKILL.md is gone — treat as missing and re-seed.
const fs = memFs({ [SCRIPT]: "// stale" });
const { seeded } = await seedBuiltinSkills({
skillsFolderRelPath: FOLDER,
fs,
skills: [skill(1)],
});
it("re-seeds when the SKILL.md was deleted", async () => {
// Script lingered but SKILL.md is gone — treat as missing and re-seed.
const fs = memFs({ [SCRIPT]: "// stale" });
const { seeded } = await seedBuiltinSkills({
skillsFolderRelPath: FOLDER,
fs,
skills: [skill(1)],
expect(seeded).toEqual(["copilot-web-search"]);
expect(fs.files.get(MD)).toContain("body v1");
});
expect(seeded).toEqual(["copilot-web-search"]);
expect(fs.files.get(MD)).toContain("body v1");
});
it("never touches unrelated user skills in the same folder", async () => {
const userMd = "copilot/skills/my-skill/SKILL.md";
const fs = memFs({ [userMd]: "user content" });
await seedBuiltinSkills({ skillsFolderRelPath: FOLDER, fs, skills: [skill(1)] });
it("never touches unrelated user skills in the same folder", async () => {
const userMd = "copilot/skills/my-skill/SKILL.md";
const fs = memFs({ [userMd]: "user content" });
await seedBuiltinSkills({ skillsFolderRelPath: FOLDER, fs, skills: [skill(1)] });
expect(fs.files.get(userMd)).toBe("user content");
});
it("does not overwrite a user-authored skill whose name collides with a builtin", async () => {
// A user created copilot-web-search before it became a builtin — no version marker.
const userContent =
"---\nname: copilot-web-search\ndescription: my custom search\n---\ncustom body";
const fs = memFs({ [MD]: userContent });
await seedBuiltinSkills({ skillsFolderRelPath: FOLDER, fs, skills: [skill(1)] });
expect(fs.files.get(MD)).toBe(userContent);
});
it("re-seeds when SKILL.md is current but a support file is missing (partial write recovery)", async () => {
// Simulate a crash after SKILL.md was written but before the script.
const fs = memFs({ [MD]: skill(1).skillMd }); // no SCRIPT
const { seeded } = await seedBuiltinSkills({
skillsFolderRelPath: FOLDER,
fs,
skills: [skill(1)],
expect(fs.files.get(userMd)).toBe("user content");
});
expect(seeded).toEqual(["copilot-web-search"]);
expect(fs.files.get(SCRIPT)).toBe("// script v1");
it("does not overwrite a user-authored skill whose name collides with a builtin", async () => {
// A user created copilot-web-search before it became a builtin — no version marker.
const userContent =
"---\nname: copilot-web-search\ndescription: my custom search\n---\ncustom body";
const fs = memFs({ [MD]: userContent });
await seedBuiltinSkills({ skillsFolderRelPath: FOLDER, fs, skills: [skill(1)] });
expect(fs.files.get(MD)).toBe(userContent);
});
it("re-seeds when SKILL.md is current but a support file is missing (partial write recovery)", async () => {
// Simulate a crash after SKILL.md was written but before the script.
const fs = memFs({ [MD]: skill(1).skillMd }); // no SCRIPT
const { seeded } = await seedBuiltinSkills({
skillsFolderRelPath: FOLDER,
fs,
skills: [skill(1)],
});
expect(seeded).toEqual(["copilot-web-search"]);
expect(fs.files.get(SCRIPT)).toBe("// script v1");
});
it("preserves user-modified copilot-enabled-agents when upgrading a builtin", async () => {
// User disabled codex and opencode via the toggle UI — SKILL.md was rewritten
// on disk to list only 'claude'. On the next version bump the seeder must not
// silently restore the full bundled agent list.
const disabledMd = skill(1).skillMd.replace(
"copilot-enabled-agents: claude, codex, opencode",
"copilot-enabled-agents: claude"
);
const fs = memFs({ [MD]: disabledMd, [SCRIPT]: "// script v1" });
await seedBuiltinSkills({ skillsFolderRelPath: FOLDER, fs, skills: [skill(2)] });
const written = fs.files.get(MD) ?? "";
expect(written).toContain("copilot-enabled-agents: claude\n");
expect(written).not.toContain("copilot-enabled-agents: claude, codex, opencode");
expect(written).toContain("body v2"); // bundled body was updated
});
it("creates parent directories for nested support files", async () => {
const nestedSkill: BuiltinSkill = {
...skill(1),
files: [{ path: "references/EXAMPLES.md", content: "# Examples" }],
};
const fs = memFs();
await seedBuiltinSkills({ skillsFolderRelPath: FOLDER, fs, skills: [nestedSkill] });
expect(fs.dirs.has("copilot/skills/copilot-web-search/references")).toBe(true);
expect(fs.files.get("copilot/skills/copilot-web-search/references/EXAMPLES.md")).toBe(
"# Examples"
);
});
});
it("preserves user-modified copilot-enabled-agents when upgrading a builtin", async () => {
// User disabled codex and opencode via the toggle UI — SKILL.md was rewritten
// on disk to list only 'claude'. On the next version bump the seeder must not
// silently restore the full bundled agent list.
const disabledMd = skill(1).skillMd.replace(
"copilot-enabled-agents: claude, codex, opencode",
"copilot-enabled-agents: claude"
);
const fs = memFs({ [MD]: disabledMd, [SCRIPT]: "// script v1" });
await seedBuiltinSkills({ skillsFolderRelPath: FOLDER, fs, skills: [skill(2)] });
describe("removeSeededBuiltin()", () => {
it("removes a seeded builtin folder and its files", async () => {
const fs = memFs({ [MD]: skill(1).skillMd, [SCRIPT]: "// script v1" });
const removed = await removeSeededBuiltin(FOLDER, "copilot-web-search", fs);
const written = fs.files.get(MD) ?? "";
expect(written).toContain("copilot-enabled-agents: claude\n");
expect(written).not.toContain("copilot-enabled-agents: claude, codex, opencode");
expect(written).toContain("body v2"); // bundled body was updated
});
});
describe("removeSeededBuiltin", () => {
it("removes a seeded builtin folder and its files", async () => {
const fs = memFs({ [MD]: skill(1).skillMd, [SCRIPT]: "// script v1" });
const removed = await removeSeededBuiltin(FOLDER, "copilot-web-search", fs);
expect(removed).toBe(true);
expect(fs.files.has(MD)).toBe(false);
expect(fs.files.has(SCRIPT)).toBe(false);
});
it("is a no-op when the skill folder is absent", async () => {
const fs = memFs();
expect(await removeSeededBuiltin(FOLDER, "copilot-web-search", fs)).toBe(false);
});
it("refuses to remove a user-authored skill that lacks the builtin version marker", async () => {
const userContent =
"---\nname: copilot-web-search\ndescription: my custom search\n---\ncustom body";
const fs = memFs({ [MD]: userContent });
const removed = await removeSeededBuiltin(FOLDER, "copilot-web-search", fs);
expect(removed).toBe(false);
expect(fs.files.get(MD)).toBe(userContent);
});
});
describe("inspectBuiltinSkill", () => {
it("reports 'absent' when no SKILL.md exists", async () => {
const fs = memFs();
expect(await inspectBuiltinSkill(FOLDER, "copilot-web-search", fs)).toBe("absent");
});
it("reports 'seeded' when the builtin version marker is present", async () => {
const fs = memFs({ [MD]: skill(1).skillMd });
expect(await inspectBuiltinSkill(FOLDER, "copilot-web-search", fs)).toBe("seeded");
});
it("reports 'collision' for a same-named folder without the marker", async () => {
const fs = memFs({
[MD]: "---\nname: copilot-web-search\ndescription: mine\n---\ncustom body",
});
expect(await inspectBuiltinSkill(FOLDER, "copilot-web-search", fs)).toBe("collision");
});
it("reports 'failed' when the SKILL.md can't be read", async () => {
const fs = memFs();
// Path claims to exist but read throws — a torn/permission-denied file.
fs.exists = async () => true;
fs.read = async () => {
throw new Error("EACCES");
};
expect(await inspectBuiltinSkill(FOLDER, "copilot-web-search", fs)).toBe("failed");
});
it("reports 'stale' when the on-disk marker is older than the expected version", async () => {
const fs = memFs({ [MD]: skill(1).skillMd });
expect(await inspectBuiltinSkill(FOLDER, "copilot-web-search", fs, 2)).toBe("stale");
});
it("reports 'seeded' when the on-disk marker meets or exceeds the expected version", async () => {
const fs = memFs({ [MD]: skill(2).skillMd });
expect(await inspectBuiltinSkill(FOLDER, "copilot-web-search", fs, 2)).toBe("seeded");
// A newer on-disk copy (e.g. a future plugin wrote it) is still ours.
const fsNewer = memFs({ [MD]: skill(3).skillMd });
expect(await inspectBuiltinSkill(FOLDER, "copilot-web-search", fsNewer, 2)).toBe("seeded");
expect(removed).toBe(true);
expect(fs.files.has(MD)).toBe(false);
expect(fs.files.has(SCRIPT)).toBe(false);
});
it("is a no-op when the skill folder is absent", async () => {
const fs = memFs();
expect(await removeSeededBuiltin(FOLDER, "copilot-web-search", fs)).toBe(false);
});
it("refuses to remove a user-authored skill that lacks the builtin version marker", async () => {
const userContent =
"---\nname: copilot-web-search\ndescription: my custom search\n---\ncustom body";
const fs = memFs({ [MD]: userContent });
const removed = await removeSeededBuiltin(FOLDER, "copilot-web-search", fs);
expect(removed).toBe(false);
expect(fs.files.get(MD)).toBe(userContent);
});
});
describe("inspectBuiltinSkill()", () => {
it("reports 'absent' when no SKILL.md exists", async () => {
const fs = memFs();
expect(await inspectBuiltinSkill(FOLDER, "copilot-web-search", fs)).toBe("absent");
});
it("reports 'seeded' when the builtin version marker is present", async () => {
const fs = memFs({ [MD]: skill(1).skillMd });
expect(await inspectBuiltinSkill(FOLDER, "copilot-web-search", fs)).toBe("seeded");
});
it("reports 'collision' for a same-named folder without the marker", async () => {
const fs = memFs({
[MD]: "---\nname: copilot-web-search\ndescription: mine\n---\ncustom body",
});
expect(await inspectBuiltinSkill(FOLDER, "copilot-web-search", fs)).toBe("collision");
});
it("reports 'failed' when the SKILL.md can't be read", async () => {
const fs = memFs();
// Path claims to exist but read throws — a torn/permission-denied file.
fs.exists = async () => true;
fs.read = async () => {
throw new Error("EACCES");
};
expect(await inspectBuiltinSkill(FOLDER, "copilot-web-search", fs)).toBe("failed");
});
it("reports 'stale' when the on-disk marker is older than the expected version", async () => {
const fs = memFs({ [MD]: skill(1).skillMd });
expect(await inspectBuiltinSkill(FOLDER, "copilot-web-search", fs, 2)).toBe("stale");
});
it("reports 'seeded' when the on-disk marker meets or exceeds the expected version", async () => {
const fs = memFs({ [MD]: skill(2).skillMd });
expect(await inspectBuiltinSkill(FOLDER, "copilot-web-search", fs, 2)).toBe("seeded");
// A newer on-disk copy (e.g. a future plugin wrote it) is still ours.
const fsNewer = memFs({ [MD]: skill(3).skillMd });
expect(await inspectBuiltinSkill(FOLDER, "copilot-web-search", fsNewer, 2)).toBe("seeded");
});
});
});

View file

@ -1,5 +1,5 @@
import { logError, logInfo } from "@/logger";
import { joinPosix } from "@/utils/pathUtils";
import { joinPosix, parentDir } from "@/utils/pathUtils";
import { BUILTIN_SKILLS, type BuiltinSkill } from "./builtinSkills";
/**
@ -174,7 +174,9 @@ export async function seedBuiltinSkills(
// leaves no SKILL.md (or a stale-version one), so the next startup
// re-seeds the whole skill rather than skipping it as current.
for (const file of skill.files) {
await fs.write(joinPosix(dir, file.path), file.content);
const filePath = joinPosix(dir, file.path);
await ensureDir(fs, parentDir(filePath));
await fs.write(filePath, file.content);
}
await fs.write(skillMdPath, skillMd);
seeded.push(skill.name);