opencode-copilot-plugin
Advanced tools
+291
-5
| // index.ts | ||
| import * as path8 from "node:path"; | ||
| import * as path11 from "node:path"; | ||
@@ -758,3 +758,255 @@ // src/file-tracker.ts | ||
| } | ||
| // src/prompts/discovery.ts | ||
| import * as path8 from "node:path"; | ||
| import * as os7 from "node:os"; | ||
| import * as fs5 from "node:fs/promises"; | ||
| import matter4 from "gray-matter"; | ||
| var LOCAL_PROMPTS_SUBDIR = path8.join(".github", "prompts"); | ||
| var GLOBAL_PROMPTS_DIR = path8.join(os7.homedir(), ".copilot", "prompts"); | ||
| async function discoverLocalPrompts(rootDir) { | ||
| return collectPromptFiles(path8.join(rootDir, LOCAL_PROMPTS_SUBDIR), "local"); | ||
| } | ||
| async function discoverGlobalPrompts(globalDir = GLOBAL_PROMPTS_DIR) { | ||
| return collectPromptFiles(globalDir, "global"); | ||
| } | ||
| function mergePrompts(local, global) { | ||
| const localNames = new Set(local.map((p) => p.name)); | ||
| const filteredGlobal = global.filter((p) => { | ||
| if (localNames.has(p.name)) { | ||
| process.stderr.write(`[opencode-copilot-plugin] Skipping global prompt "${p.name}": overridden by a local prompt with the same name | ||
| `); | ||
| return false; | ||
| } | ||
| return true; | ||
| }); | ||
| return [...local, ...filteredGlobal]; | ||
| } | ||
| async function collectPromptFiles(dir, scope) { | ||
| const results = []; | ||
| let entries; | ||
| try { | ||
| entries = await fs5.readdir(dir, { withFileTypes: true, encoding: "utf8" }); | ||
| } catch { | ||
| return results; | ||
| } | ||
| for (const entry of entries) { | ||
| if (!entry.isFile() || !entry.name.endsWith(".prompt.md")) | ||
| continue; | ||
| const filePath = path8.join(dir, entry.name); | ||
| const prompt = await parsePromptFile(filePath, dir, scope); | ||
| if (prompt) | ||
| results.push(prompt); | ||
| } | ||
| return results; | ||
| } | ||
| async function parsePromptFile(filePath, dirPath, scope) { | ||
| let raw; | ||
| try { | ||
| raw = await fs5.readFile(filePath, "utf8"); | ||
| } catch { | ||
| return null; | ||
| } | ||
| const { data: frontmatter, content } = matter4(raw); | ||
| const fm = frontmatter; | ||
| const name = derivePromptName(path8.basename(filePath)); | ||
| const fmName = fm.name; | ||
| if (typeof fmName === "string" && fmName !== name) { | ||
| process.stderr.write(`[opencode-copilot-plugin] Warning: "name" in ${filePath} is "${fmName}" but filename-derived name is "${name}". Using filename-derived name as canonical name. | ||
| `); | ||
| } | ||
| const description = typeof fm.description === "string" && fm.description.trim() ? fm.description.trim() : name; | ||
| const trimmedContent = content.trim(); | ||
| const args = extractArguments(trimmedContent); | ||
| return { | ||
| name, | ||
| description, | ||
| scope, | ||
| dirPath, | ||
| filePath, | ||
| content: trimmedContent, | ||
| frontmatter: fm, | ||
| arguments: args | ||
| }; | ||
| } | ||
| function derivePromptName(filename) { | ||
| if (filename.endsWith(".prompt.md")) | ||
| return filename.slice(0, -".prompt.md".length); | ||
| return filename; | ||
| } | ||
| function extractArguments(content) { | ||
| const seen = new Set; | ||
| const args = []; | ||
| const vscodePattern = /\$\{input:([^}:]+)(?::([^}]*))?\}/g; | ||
| let match; | ||
| while ((match = vscodePattern.exec(content)) !== null) { | ||
| const id = match[1].trim(); | ||
| const placeholder = match[2]?.trim(); | ||
| if (!seen.has(id)) { | ||
| seen.add(id); | ||
| args.push({ id, placeholder: placeholder || undefined }); | ||
| } | ||
| } | ||
| const crushPattern = /(?<!\{)\$([A-Z][A-Z0-9_]*)(?!\{)/g; | ||
| while ((match = crushPattern.exec(content)) !== null) { | ||
| const id = match[1]; | ||
| if (!seen.has(id)) { | ||
| seen.add(id); | ||
| args.push({ id }); | ||
| } | ||
| } | ||
| return args; | ||
| } | ||
| function substituteArguments(content, args, argValues) { | ||
| let result = content; | ||
| for (const arg of args) { | ||
| const value = argValues[arg.id]; | ||
| if (value === undefined) | ||
| continue; | ||
| result = result.replace(new RegExp(`\\$\\{input:${escapeRegex(arg.id)}(?::[^}]*)?\\}`, "g"), value); | ||
| result = result.replace(new RegExp(`(?<!\\{)\\$${escapeRegex(arg.id)}(?!\\{)`, "g"), value); | ||
| } | ||
| return result; | ||
| } | ||
| function escapeRegex(str) { | ||
| return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | ||
| } | ||
| // src/prompts/resolve-references.ts | ||
| import * as path9 from "node:path"; | ||
| import * as fs6 from "node:fs/promises"; | ||
| var MAX_TOTAL_RESOLVED_BYTES = 1e5; | ||
| var MAX_REFERENCE_DEPTH = 5; | ||
| async function resolveFileReferences(content, promptDir) { | ||
| const state = { totalBytes: 0, visited: new Set }; | ||
| return resolveContent(content, promptDir, state, 0); | ||
| } | ||
| async function resolveContent(content, baseDir, state, depth) { | ||
| if (depth >= MAX_REFERENCE_DEPTH) | ||
| return content; | ||
| const linkPattern = /\[([^\]]*)\]\(([^)]+)\)/g; | ||
| const segments = []; | ||
| let lastIndex = 0; | ||
| let match; | ||
| while ((match = linkPattern.exec(content)) !== null) { | ||
| const fullMatch = match[0]; | ||
| const label = match[1] ?? ""; | ||
| const href = match[2] ?? ""; | ||
| if (isUrl(href) || href.startsWith("#")) { | ||
| segments.push(content.slice(lastIndex, match.index + fullMatch.length)); | ||
| lastIndex = match.index + fullMatch.length; | ||
| continue; | ||
| } | ||
| segments.push(content.slice(lastIndex, match.index)); | ||
| lastIndex = match.index + fullMatch.length; | ||
| const absolutePath = path9.resolve(baseDir, href); | ||
| if (state.visited.has(absolutePath)) { | ||
| process.stderr.write(`[opencode-copilot-plugin] Skipping circular reference to ${absolutePath} in prompt | ||
| `); | ||
| segments.push(fullMatch); | ||
| continue; | ||
| } | ||
| if (state.totalBytes >= MAX_TOTAL_RESOLVED_BYTES) { | ||
| segments.push(fullMatch); | ||
| continue; | ||
| } | ||
| let fileContent; | ||
| try { | ||
| fileContent = await fs6.readFile(absolutePath, "utf8"); | ||
| } catch { | ||
| process.stderr.write(`[opencode-copilot-plugin] Warning: could not read referenced file "${absolutePath}" — leaving link as-is | ||
| `); | ||
| segments.push(fullMatch); | ||
| continue; | ||
| } | ||
| state.visited.add(absolutePath); | ||
| state.totalBytes += Buffer.byteLength(fileContent, "utf8"); | ||
| const resolvedFileContent = await resolveContent(fileContent, path9.dirname(absolutePath), state, depth + 1); | ||
| state.visited.delete(absolutePath); | ||
| const relPath = href; | ||
| const block = buildReferencedFileBlock(label, relPath, resolvedFileContent); | ||
| segments.push(block); | ||
| } | ||
| segments.push(content.slice(lastIndex)); | ||
| return segments.join(""); | ||
| } | ||
| function buildReferencedFileBlock(label, relPath, content) { | ||
| const lines = [ | ||
| `<referenced_file path="${relPath}">`, | ||
| content, | ||
| `</referenced_file>` | ||
| ]; | ||
| if (label.trim()) { | ||
| return `${label} | ||
| ${lines.join(` | ||
| `)}`; | ||
| } | ||
| return lines.join(` | ||
| `); | ||
| } | ||
| function isUrl(href) { | ||
| return /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(href); | ||
| } | ||
| // src/prompts/format.ts | ||
| import * as os8 from "node:os"; | ||
| import * as path10 from "node:path"; | ||
| var home3 = os8.homedir(); | ||
| function toDisplayPath3(filePath) { | ||
| return filePath.startsWith(home3) ? filePath.replace(home3, "~") : filePath; | ||
| } | ||
| function formatPromptHeader(prompt) { | ||
| const displayPath = toDisplayPath3(prompt.filePath); | ||
| const scopeLabel = prompt.scope === "local" ? ".github/prompts" : "~/.copilot/prompts"; | ||
| const fileName = path10.basename(prompt.filePath); | ||
| const lines = [ | ||
| `## Copilot Prompt: ${prompt.name} (from \`${scopeLabel}/${fileName}\`)` | ||
| ]; | ||
| if (prompt.description && prompt.description !== prompt.name) { | ||
| lines.push(`> ${prompt.description}`); | ||
| } | ||
| const notes = buildInformationalNotes(prompt); | ||
| if (notes.length > 0) { | ||
| lines.push("", ...notes); | ||
| } | ||
| lines.push(""); | ||
| return lines.join(` | ||
| `); | ||
| } | ||
| function buildInformationalNotes(prompt) { | ||
| const notes = []; | ||
| const fm = prompt.frontmatter; | ||
| if (fm.agent) { | ||
| notes.push(`> **Note:** This prompt specifies agent \`${fm.agent}\` — informational only in OpenCode.`); | ||
| } | ||
| if (fm.model) { | ||
| notes.push(`> **Note:** This prompt prefers model \`${fm.model}\` — informational only in OpenCode.`); | ||
| } | ||
| if (fm.tools && fm.tools.length > 0) { | ||
| const toolList = fm.tools.map((t) => `\`${t}\``).join(", "); | ||
| notes.push(`> **Note:** This prompt specifies tools [${toolList}] — informational only in OpenCode.`); | ||
| } | ||
| return notes; | ||
| } | ||
| function parseCommandArguments(rawArguments, promptArgs) { | ||
| const result = {}; | ||
| if (!rawArguments.trim()) | ||
| return result; | ||
| const hasKeyValue = rawArguments.includes("="); | ||
| if (hasKeyValue) { | ||
| const kvPattern = /([A-Za-z_][A-Za-z0-9_]*)=("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s]+)/g; | ||
| let match; | ||
| while ((match = kvPattern.exec(rawArguments)) !== null) { | ||
| const key = match[1]; | ||
| let value = match[2]; | ||
| if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) { | ||
| value = value.slice(1, -1).replace(/\\(.)/g, "$1"); | ||
| } | ||
| result[key] = value; | ||
| } | ||
| } else if (promptArgs.length > 0) { | ||
| const firstArg = promptArgs[0]; | ||
| if (firstArg) { | ||
| result[firstArg.id] = rawArguments.trim(); | ||
| } | ||
| } | ||
| return result; | ||
| } | ||
| // index.ts | ||
@@ -776,5 +1028,9 @@ var FILE_TOOLS = new Set(["read", "edit", "write", "patch"]); | ||
| let allAgents = mergeAgents(localAgents, globalAgents); | ||
| const localAgentsDir = path8.join(rootDir, LOCAL_AGENTS_SUBDIR); | ||
| const localSkillsDir = path8.join(rootDir, LOCAL_SKILLS_SUBDIR); | ||
| await log(client, "info", `Loaded ${projectInstructions.length} project instruction(s), ` + `${globalInstructions.length} global instruction(s), ` + `${allSkills.length} skill(s) (${localSkills.length} local, ${globalSkills.length} global), ` + `${Object.keys(hookRegistry).length} hook type(s), ` + `${allAgents.length} agent(s) (${localAgents.length} local, ${globalAgents.length} global)`); | ||
| let localPrompts = await discoverLocalPrompts(rootDir); | ||
| let globalPrompts = await discoverGlobalPrompts(); | ||
| let allPrompts = mergePrompts(localPrompts, globalPrompts); | ||
| const localAgentsDir = path11.join(rootDir, LOCAL_AGENTS_SUBDIR); | ||
| const localSkillsDir = path11.join(rootDir, LOCAL_SKILLS_SUBDIR); | ||
| const localPromptsDir = path11.join(rootDir, LOCAL_PROMPTS_SUBDIR); | ||
| await log(client, "info", `Loaded ${projectInstructions.length} project instruction(s), ` + `${globalInstructions.length} global instruction(s), ` + `${allSkills.length} skill(s) (${localSkills.length} local, ${globalSkills.length} global), ` + `${Object.keys(hookRegistry).length} hook type(s), ` + `${allAgents.length} agent(s) (${localAgents.length} local, ${globalAgents.length} global), ` + `${allPrompts.length} prompt(s) (${localPrompts.length} local, ${globalPrompts.length} global)`); | ||
| const hooks = { | ||
@@ -912,2 +1168,13 @@ "experimental.chat.system.transform": async (input, output) => { | ||
| }, | ||
| "command.execute.before": async (input, output) => { | ||
| const commandBaseName = extractCommandBaseName(input.command); | ||
| const prompt = allPrompts.find((p) => p.name === commandBaseName); | ||
| if (!prompt) | ||
| return; | ||
| const resolvedContent = await resolveFileReferences(prompt.content, prompt.dirPath); | ||
| const argValues = parseCommandArguments(input.arguments, prompt.arguments); | ||
| const finalContent = substituteArguments(resolvedContent, prompt.arguments, argValues); | ||
| const header = formatPromptHeader(prompt); | ||
| output.parts = [{ type: "text", text: header + finalContent }]; | ||
| }, | ||
| event: async ({ event }) => { | ||
@@ -922,2 +1189,4 @@ if (event.type === "file.watcher.updated") { | ||
| const isGlobalAgent = changedPath.startsWith(GLOBAL_AGENTS_DIR); | ||
| const isLocalPrompt = changedPath.startsWith(localPromptsDir); | ||
| const isGlobalPrompt = changedPath.startsWith(GLOBAL_PROMPTS_DIR); | ||
| if (isProjectInstruction) { | ||
@@ -951,2 +1220,12 @@ projectInstructions = await discoverInstructions(rootDir); | ||
| } | ||
| if (isLocalPrompt) { | ||
| localPrompts = await discoverLocalPrompts(rootDir); | ||
| allPrompts = mergePrompts(localPrompts, globalPrompts); | ||
| await log(client, "info", `Hot-reloaded ${allPrompts.length} prompt(s) after change to ${changedPath}`); | ||
| } | ||
| if (isGlobalPrompt) { | ||
| globalPrompts = await discoverGlobalPrompts(); | ||
| allPrompts = mergePrompts(localPrompts, globalPrompts); | ||
| await log(client, "info", `Hot-reloaded ${allPrompts.length} prompt(s) after change to ${changedPath}`); | ||
| } | ||
| } | ||
@@ -1029,2 +1308,9 @@ if (event.type === "session.created") { | ||
| }; | ||
| function extractCommandBaseName(command) { | ||
| const colonIndex = command.indexOf(":"); | ||
| if (colonIndex === -1) | ||
| return command; | ||
| const afterPrefix = command.slice(colonIndex + 1); | ||
| return afterPrefix.replace(/:/g, "/"); | ||
| } | ||
| async function log(client, level, message) { | ||
@@ -1031,0 +1317,0 @@ try { |
+1
-1
| { | ||
| "name": "opencode-copilot-plugin", | ||
| "version": "0.4.2", | ||
| "version": "0.5.0", | ||
| "description": "OpenCode plugin that brings GitHub Copilot's custom instructions and skills system to OpenCode", | ||
@@ -5,0 +5,0 @@ "type": "module", |
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
57203
22.14%1314
27.95%9
28.57%