feat(skills): ship Node fallback script for builtin Copilot Plus tools (#2548)

Windows users without Git Bash can't run the `.sh` scripts the builtin
tools (web search, PDF, YouTube, X) ship, so installation/execution
dead-ends there. Ship an equivalent Node `.mjs` script alongside each
`.sh` and teach the agent the fallback chain.

- builtinSkills.ts: add `nodeScriptPreamble()` (global fetch, node: core
  modules only, no npm deps) mirroring the shell script's env reads,
  relay call, 401/403 → upgrade mapping, and exit codes. Each skill now
  ships both a `.sh` and a `.mjs`.
- SKILL.md "How to run" (shared `howToRunSection` helper): try `sh`
  first, fall back to `node <script>.mjs` when the platform can't run sh,
  and prompt the user to install Node.js if neither runtime exists.
- agentSystemPrompt.ts: extend COPILOT_PLUS_TOOLS_STEERING with the same
  sh → node → install-Node guidance.
- Bump skill versions (relay 2→3, read-pdf 3→4) so seeded copies refresh.

Closes #115

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Logan Yang 2026-06-01 14:50:46 -07:00 committed by GitHub
parent cc5f2782d7
commit 3ba94de0cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 209 additions and 63 deletions

View file

@ -54,6 +54,8 @@ For these requests, prefer the bundled Copilot skill over any built-in tool of y
- Getting a YouTube video's transcript the \`copilot-youtube-transcript\` skill
- Fetching an X (Twitter) post the \`copilot-fetch-x\` skill
Each skill ships both a \`.sh\` and a \`.mjs\` script. Run the \`.sh\` with \`sh\` first; if the platform can't run \`sh\` (for example, Windows without Git Bash), run the matching \`.mjs\` with \`node\` instead. If neither \`sh\` nor \`node\` is available, tell the user to install Node.js from https://nodejs.org and try again.
If the matching skill is missing, disabled, or reports that it needs an active Copilot Plus license, fall back to whatever equivalent capability you have (or tell the user it's unavailable) — don't refuse the request.`;
export const COPILOT_PROMPT_BASE = `You are Obsidian Copilot, an AI assistant that helps users work with their Obsidian vault — markdown notes for knowledge management, writing, and research. You are NOT a software-engineering agent or CLI coding tool. The working directory is the user's Obsidian vault: a collection of markdown notes, not a code repository. Disregard any framing in environment metadata that suggests otherwise.

View file

@ -1,10 +1,12 @@
import { BUILTIN_SKILLS, PLUS_ENV } from "./builtinSkills";
/** The single script file shipped by a skill. */
function scriptOf(name: string): string {
/** A script file shipped by a skill, matched by extension (".sh" or ".mjs"). */
function scriptOf(name: string, ext: ".sh" | ".mjs" = ".sh"): string {
const skill = BUILTIN_SKILLS.find((s) => s.name === name);
if (!skill) throw new Error(`no builtin skill ${name}`);
return skill.files[0].content;
const file = skill.files.find((f) => f.path.endsWith(ext));
if (!file) throw new Error(`skill ${name} ships no ${ext} script`);
return file.content;
}
describe("builtin Copilot Plus skills", () => {
@ -26,43 +28,73 @@ describe("builtin Copilot Plus skills", () => {
}
});
it("invokes the script with sh (not node) and references the matching script file", () => {
it("ships both an sh and a node script, and documents the sh → node fallback", () => {
for (const skill of BUILTIN_SKILLS) {
const scriptFile = skill.files[0].path;
expect(scriptFile).toMatch(/\.sh$/);
expect(skill.skillMd).toContain(`sh "/absolute/path/to/this/skill/directory/${scriptFile}"`);
expect(skill.skillMd).not.toContain("node ");
const sh = skill.files.find((f) => f.path.endsWith(".sh"));
const mjs = skill.files.find((f) => f.path.endsWith(".mjs"));
expect(sh).toBeDefined();
expect(mjs).toBeDefined();
// The two scripts share a base name (web-search.sh ↔ web-search.mjs).
expect(mjs!.path).toBe(sh!.path.replace(/\.sh$/, ".mjs"));
// SKILL.md tells the agent to prefer sh, fall back to node, then prompt
// for a Node install if neither runtime is available.
expect(skill.skillMd).toContain(`sh "/absolute/path/to/this/skill/directory/${sh!.path}"`);
expect(skill.skillMd).toContain(
`node "/absolute/path/to/this/skill/directory/${mjs!.path}"`
);
expect(skill.skillMd).toContain("install Node.js");
}
});
it("reads its config from the injected env and never embeds a key", () => {
it("reads its config from the injected env and never embeds a key (both scripts)", () => {
for (const skill of BUILTIN_SKILLS) {
const script = skill.files[0].content;
expect(script).toContain(`#!/bin/sh`);
expect(script).toContain(PLUS_ENV.licenseKey);
expect(script).toContain(PLUS_ENV.baseUrl);
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(script).toContain("Authorization: Bearer $KEY");
expect(script).toContain("X-Client-Version: $CLIENT_VERSION");
expect(sh).toContain("Authorization: Bearer $KEY");
expect(sh).toContain("X-Client-Version: $CLIENT_VERSION");
// Guard + upgrade prompt when the license/relay config is absent.
expect(script).toContain('[ -n "$KEY" ] && [ -n "$BASE" ] || die "$UPGRADE"');
expect(script).toContain("Copilot Plus");
expect(sh).toContain('[ -n "$KEY" ] && [ -n "$BASE" ] || die "$UPGRADE"');
expect(sh).toContain("Copilot Plus");
const mjs = scriptOf(skill.name, ".mjs");
expect(mjs).toContain(`#!/usr/bin/env node`);
expect(mjs).toContain(`process.env.${PLUS_ENV.licenseKey}`);
expect(mjs).toContain(`process.env.${PLUS_ENV.baseUrl}`);
expect(mjs).toContain('Authorization: "Bearer " + KEY');
expect(mjs).toContain('"X-Client-Version": CLIENT_VERSION');
// Same license guard as the shell script.
expect(mjs).toContain("if (!KEY || !BASE) die(UPGRADE);");
expect(mjs).toContain("Copilot Plus");
}
});
it("maps each relay tool to its endpoint and request body", () => {
expect(scriptOf("copilot-web-search")).toContain('relay "/websearch"');
expect(scriptOf("copilot-web-search")).toContain('\\"query\\"');
expect(scriptOf("copilot-youtube-transcript")).toContain('relay "/youtube4llm"');
expect(scriptOf("copilot-fetch-x")).toContain('relay "/twitter4llm"');
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")).toContain('$(json_escape "$ARG")');
expect(scriptOf("copilot-web-search", ".sh")).toContain('$(json_escape "$ARG")');
// The node fallback hits the same endpoints with a structured body.
expect(scriptOf("copilot-web-search", ".mjs")).toContain('await relay("/websearch"');
expect(scriptOf("copilot-web-search", ".mjs")).toContain("query: ARG, user_id: USER_ID");
expect(scriptOf("copilot-youtube-transcript", ".mjs")).toContain('await relay("/youtube4llm"');
expect(scriptOf("copilot-fetch-x", ".mjs")).toContain('await relay("/twitter4llm"');
});
it("read-pdf base64-encodes the file into the pdf field", () => {
const pdf = scriptOf("copilot-read-pdf");
expect(pdf).toContain('relay "/pdf4llm"');
expect(pdf).toContain("base64");
expect(pdf).toContain('\\"pdf\\"');
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 mjs = scriptOf("copilot-read-pdf", ".mjs");
expect(mjs).toContain('await relay("/pdf4llm"');
expect(mjs).toContain('toString("base64")');
expect(mjs).toContain("pdf: PDF, user_id: USER_ID");
});
});

View file

@ -6,19 +6,23 @@ import type { BackendId } from "@/agentMode/session/types";
* these are seeded into the canonical skills folder by the plugin (see
* `seedBuiltinSkills`) and refreshed when `version` bumps.
*
* Each skill ships a `SKILL.md` (instructions the agent reads) plus one
* POSIX `sh` script the agent runs with `curl`. The script reads the Copilot
* Plus license + relay base URL from env vars the plugin injects at spawn time
* (see `buildCopilotPlusEnv`) and calls the Brevilabs relay directly no key
* is embedded in the skill files. A missing/invalid license makes the script
* exit non-zero with an upgrade prompt the agent relays to the user.
* Each skill ships a `SKILL.md` (instructions the agent reads) plus two
* runnable scripts: a POSIX `sh` script (run with `curl`) and an equivalent
* Node `.mjs` script. Both 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. A
* missing/invalid license makes the script exit non-zero with an upgrade prompt
* the agent relays to the user.
*
* Why `sh` + `curl` rather than a Node script: the script runs in the *agent's*
* shell, where `node` is not reliably on PATH (Obsidian launches with a minimal
* PATH that usually excludes nvm/Volta/Homebrew node). `sh`, `curl`, `sed`, and
* `base64` live in `/usr/bin` (and git-bash on Windows), so they are reachable
* regardless of the user's node setup. Scripts are POSIX and invoked as
* `sh "<path>" <arg>`, so they need neither a node runtime nor an executable bit.
* Why ship both an `sh` and a Node script: `sh` + `curl` is preferred because
* the script runs in the *agent's* shell, where `sh`, `curl`, `sed`, and
* `base64` live in `/usr/bin` and are reachable regardless of the user's node
* setup (Obsidian launches with a minimal PATH that usually excludes nvm/Volta/
* Homebrew node). But Windows has no `sh` unless Git Bash is installed, so each
* skill also ships a Node fallback. SKILL.md tells the agent to try `sh` first,
* fall back to `node <script>.mjs`, and if neither runtime exists prompt the
* user to install Node.js. Scripts need neither extra imports nor an executable
* bit.
*/
export interface BuiltinSkill {
/** Folder name + SKILL.md `name`. Kebab-case, Copilot-branded. */
@ -106,6 +110,103 @@ relay() {
`;
}
/**
* Node equivalent of {@link scriptPreamble}, shipped alongside each `.sh` so
* Windows users (no `sh` unless Git Bash is installed) still have a runnable
* script. Uses Node's global `fetch` (Node 18+) and the built-in `node:` core
* modules only no npm deps, so it runs from a bare vault folder. The `.mjs`
* extension forces ESM regardless of any ambient `package.json`, which lets the
* per-skill tail use top-level `await`.
*
* Behaviour mirrors the shell script exactly: same env vars, same relay call,
* same 401/403 upgrade-prompt mapping, same non-zero exits. If the runtime is
* older than Node 18 (`fetch` undefined) it exits with a "update Node" message
* rather than failing obscurely.
*/
function nodeScriptPreamble(): string {
return `#!/usr/bin/env node
// Node fallback for the matching .sh script — for platforms that can't run sh
// (e.g. Windows without Git Bash). Calls the Brevilabs relay and prints the
// JSON result to stdout. Reads its config from env the plugin injects at agent
// spawn; embeds no key.
const BASE = process.env.${PLUS_ENV.baseUrl} || "";
const KEY = process.env.${PLUS_ENV.licenseKey} || "";
const USER_ID = process.env.${PLUS_ENV.userId} || "";
const CLIENT_VERSION = process.env.${PLUS_ENV.clientVersion} || "";
const UPGRADE = ${JSON.stringify(UPGRADE_MESSAGE)};
function die(message, code = 2) {
process.stderr.write(String(message) + "\\n");
process.exit(code);
}
if (!KEY || !BASE) die(UPGRADE);
// relay(endpoint, body) -> prints the response body, mapping HTTP status.
async function relay(endpoint, body) {
if (typeof fetch !== "function") {
die("This fallback needs Node 18 or newer (global fetch). Ask the user to update Node.js.", 1);
}
let resp;
try {
resp = await fetch(BASE + endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + KEY,
"X-Client-Version": CLIENT_VERSION,
},
body: JSON.stringify(body),
});
} catch {
die("Could not reach the Copilot relay.", 1);
}
const out = await resp.text();
if (resp.status === 401 || resp.status === 403) die(UPGRADE);
if (resp.status >= 200 && resp.status < 300) {
process.stdout.write(out + "\\n");
} else {
die("Request failed (HTTP " + resp.status + "): " + out, 1);
}
}
`;
}
/**
* The SKILL.md "How to run" section shared by every builtin skill. Documents
* the `sh` `node` install-Node fallback chain so the agent never dead-ends
* on a platform that lacks one runtime. `extraNote` appends a skill-specific
* sentence (e.g. PDF's "pass an absolute path") before the trailing line.
*/
function howToRunSection(opts: {
shFile: string;
nodeFile: string;
argPlaceholder: string;
extraNote?: string;
}): string {
const dir = "/absolute/path/to/this/skill/directory";
return `## How to run
Find the absolute path to this SKILL.md file on disk, then run the script that
sits next to it. Prefer the POSIX shell version:
\`\`\`bash
sh "${dir}/${opts.shFile}" "${opts.argPlaceholder}"
\`\`\`
If your platform can't run \`sh\` (for example, Windows without Git Bash), run
the Node version that sits in the same folder instead:
\`\`\`bash
node "${dir}/${opts.nodeFile}" "${opts.argPlaceholder}"
\`\`\`
If neither \`sh\` nor \`node\` is available, tell the user to install Node.js
from https://nodejs.org and run the command again.${opts.extraNote ? ` ${opts.extraNote}` : ""}
Both scripts print the result to stdout.`;
}
/**
* Build a skill that maps a single positional argument onto one relay
* endpoint (the web/YouTube/X tools are identical apart from the endpoint,
@ -124,7 +225,8 @@ function relaySkill(opts: {
scriptFile: string;
}): BuiltinSkill {
const [argKey, argPlaceholder] = opts.arg;
const version = 2;
const nodeScriptFile = opts.scriptFile.replace(/\.sh$/, ".mjs");
const version = 3;
return {
name: opts.name,
version,
@ -142,16 +244,7 @@ metadata:
${opts.intro}
## How to run
Find the absolute path to this SKILL.md file on disk, then run the script that
sits next to it:
\`\`\`bash
sh "/absolute/path/to/this/skill/directory/${opts.scriptFile}" "${argPlaceholder}"
\`\`\`
The script prints the result to stdout.
${howToRunSection({ shFile: opts.scriptFile, nodeFile: nodeScriptFile, argPlaceholder })}
## If it reports a license problem
@ -166,6 +259,14 @@ or renew — then continue without it.
ARG="$*"
[ -n "$ARG" ] || die "Usage: sh ${opts.scriptFile} <${argKey}>" 1
relay "${opts.endpoint}" "{\\"${argKey}\\":\\"$(json_escape "$ARG")\\",\\"user_id\\":\\"$(json_escape "$USER_ID")\\"}"
`,
},
{
path: nodeScriptFile,
content: `${nodeScriptPreamble()}
const ARG = process.argv.slice(2).join(" ");
if (!ARG) die("Usage: node ${nodeScriptFile} <${argKey}>", 1);
await relay("${opts.endpoint}", { ${argKey}: ARG, user_id: USER_ID });
`,
},
],
@ -183,7 +284,7 @@ const WEB_SEARCH = relaySkill({
scriptFile: "web-search.sh",
});
const READ_PDF_VERSION = 3;
const READ_PDF_VERSION = 4;
const READ_PDF: BuiltinSkill = {
name: "copilot-read-pdf",
version: READ_PDF_VERSION,
@ -202,17 +303,12 @@ metadata:
Convert a PDF file to Markdown text through Copilot Plus so you can read,
summarize, or quote it.
## How to run
Find the absolute path to this SKILL.md file on disk, then run the script that
sits next to it:
\`\`\`bash
sh "/absolute/path/to/this/skill/directory/read-pdf.sh" "<path-to-file.pdf>"
\`\`\`
Pass an absolute path to the PDF file. The script prints the extracted Markdown
to stdout.
${howToRunSection({
shFile: "read-pdf.sh",
nodeFile: "read-pdf.mjs",
argPlaceholder: "<path-to-file.pdf>",
extraNote: "Pass an absolute path to the PDF file.",
})}
## If it reports a license problem
@ -231,6 +327,22 @@ FILE=\${1:-}
# Mirror brevilabsClient.ts pdf4llm: JSON body with base64-encoded pdf field.
PDF=$(base64 < "$FILE" | tr -d '\\n')
relay "/pdf4llm" "{\\"pdf\\":\\"$PDF\\",\\"user_id\\":\\"$(json_escape "$USER_ID")\\"}"
`,
},
{
path: "read-pdf.mjs",
content: `${nodeScriptPreamble()}
const FILE = process.argv[2] || "";
if (!FILE) die("Usage: node read-pdf.mjs <path-to-file.pdf>", 1);
let PDF;
try {
// Mirror brevilabsClient.ts pdf4llm: JSON body with base64-encoded pdf field.
const { readFileSync } = await import("node:fs");
PDF = readFileSync(FILE).toString("base64");
} catch {
die("Could not read file: " + FILE, 1);
}
await relay("/pdf4llm", { pdf: PDF, user_id: USER_ID });
`,
},
],