mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
cc5f2782d7
commit
3ba94de0cb
3 changed files with 209 additions and 63 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
`,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in a new issue