@dlacaille/opencode-copilot-instructions 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,9 +35,9 @@ Always use explicit return types.
35
35
 
36
36
  When the `read`, `edit`, or `write` tools touch a matching file, the instructions are appended to the tool result. Each file is added once per session, and again after the session is compacted.
37
37
 
38
- `applyTo` accepts a comma-separated string or a YAML list of [picomatch](https://github.com/micromatch/picomatch) globs, relative to the project directory.
38
+ `applyTo` accepts a comma-separated string or a YAML list of [picomatch](https://github.com/micromatch/picomatch) globs, relative to the git root.
39
39
 
40
- Instructions are loaded when the plugin starts. Restart OpenCode after changing them.
40
+ Instructions are read from the `.github` folder at the git root of each session's directory (or the directory itself outside a git repository). They are reloaded on every use, so edits apply without restarting OpenCode.
41
41
 
42
42
  ## Development
43
43
 
package/index.ts CHANGED
@@ -1 +1 @@
1
- export { default } from "./src/index"
1
+ export { default } from "./src/index";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dlacaille/opencode-copilot-instructions",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "OpenCode plugin that loads GitHub Copilot custom instruction files",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,7 +22,9 @@
22
22
  ],
23
23
  "scripts": {
24
24
  "test": "vitest run",
25
- "typecheck": "tsc --noEmit"
25
+ "typecheck": "tsc --noEmit",
26
+ "lint": "biome check",
27
+ "format": "biome check --write"
26
28
  },
27
29
  "dependencies": {
28
30
  "front-matter": "^4.0.2",
@@ -32,6 +34,7 @@
32
34
  "@opencode/plugin": ">=2.0.0"
33
35
  },
34
36
  "devDependencies": {
37
+ "@biomejs/biome": "2.5.14",
35
38
  "@opencode/plugin": "^2.0.15",
36
39
  "@types/node": "^22.0.0",
37
40
  "@types/picomatch": "^4.0.0",
@@ -1,38 +1,38 @@
1
- import * as frontMatterModule from "front-matter"
2
-
3
- const fm = (frontMatterModule as any).default ?? frontMatterModule
1
+ import fm from "front-matter";
4
2
 
5
3
  export interface Frontmatter {
6
- applyTo?: string | string[]
4
+ applyTo?: string | string[];
7
5
  }
8
6
 
9
7
  export interface ParsedFrontmatter {
10
- frontmatter: Frontmatter
11
- body: string
8
+ frontmatter: Frontmatter;
9
+ body: string;
12
10
  }
13
11
 
14
12
  export function parseFrontmatter(content: string): ParsedFrontmatter {
15
- const normalized = content.replace(/\r\n/g, "\n")
13
+ const normalized = content.replace(/\r\n/g, "\n");
16
14
 
17
15
  if (!fm.test(normalized)) {
18
- return { frontmatter: {}, body: content }
16
+ return { frontmatter: {}, body: content };
19
17
  }
20
18
 
21
19
  try {
22
- const parsed = fm(normalized)
23
- const attrs = parsed.attributes as Record<string, unknown>
24
- const result: Frontmatter = {}
20
+ const parsed = fm(normalized);
21
+ const attrs = parsed.attributes as Record<string, unknown>;
22
+ const result: Frontmatter = {};
25
23
 
26
24
  if (attrs.applyTo !== undefined) {
27
25
  if (typeof attrs.applyTo === "string") {
28
- result.applyTo = attrs.applyTo
26
+ result.applyTo = attrs.applyTo;
29
27
  } else if (Array.isArray(attrs.applyTo)) {
30
- result.applyTo = attrs.applyTo.filter((item): item is string => typeof item === "string")
28
+ result.applyTo = attrs.applyTo.filter(
29
+ (item): item is string => typeof item === "string",
30
+ );
31
31
  }
32
32
  }
33
33
 
34
- return { frontmatter: result, body: parsed.body }
34
+ return { frontmatter: result, body: parsed.body };
35
35
  } catch {
36
- return { frontmatter: {}, body: content }
36
+ return { frontmatter: {}, body: content };
37
37
  }
38
38
  }
package/src/index.ts CHANGED
@@ -1,95 +1,141 @@
1
- import * as path from "node:path"
2
- import { Plugin } from "@opencode/plugin"
3
- import { loadRepoInstructions, loadPathInstructions } from "./loader"
4
- import { SessionState } from "./session-state"
5
-
6
- const FILE_TOOLS = new Set(["read", "edit", "write"])
1
+ import * as path from "node:path";
2
+ import { Plugin } from "@opencode/plugin";
3
+ import {
4
+ findRepoRoot,
5
+ loadPathInstructions,
6
+ loadRepoInstructions,
7
+ } from "./loader";
8
+ import { SessionState } from "./session-state";
9
+
10
+ const FILE_TOOLS = new Set(["read", "edit", "write"]);
11
+
12
+ /** Path relative to the repo root, or null when the file is outside it. */
13
+ function getRepoRelativePath(
14
+ repoRoot: string,
15
+ sessionDirectory: string,
16
+ filePath: string,
17
+ ): string | null {
18
+ const relative = path.relative(
19
+ repoRoot,
20
+ path.resolve(sessionDirectory, filePath),
21
+ );
22
+ if (
23
+ relative === ".." ||
24
+ relative.startsWith(`..${path.sep}`) ||
25
+ path.isAbsolute(relative)
26
+ )
27
+ return null;
28
+ return relative;
29
+ }
7
30
 
8
- function getRelativePath(directory: string, filePath: string): string {
9
- const normalizedDir = directory.endsWith("/") ? directory.slice(0, -1) : directory
10
- if (!path.isAbsolute(filePath)) return filePath
11
- return path.relative(normalizedDir, filePath)
31
+ function appendInstructions<Content>(
32
+ result: { content?: string | ReadonlyArray<Content>; output?: unknown },
33
+ text: string,
34
+ ): string | ReadonlyArray<Content | { type: "text"; text: string }> {
35
+ const { content, output } = result;
36
+ if (Array.isArray(content)) return [...content, { type: "text", text }];
37
+ // Without content, the model would otherwise see only the output, so keep it in the text.
38
+ const existing =
39
+ typeof content === "string"
40
+ ? content
41
+ : output === undefined
42
+ ? ""
43
+ : typeof output === "string"
44
+ ? output
45
+ : JSON.stringify(output, null, 2);
46
+ return existing ? `${existing}\n\n${text}` : text;
12
47
  }
13
48
 
14
49
  export default Plugin.define({
15
50
  id: "copilot-instructions",
16
51
  async setup(ctx) {
17
- const directory = ctx.location.directory
18
- const repoInstructions = loadRepoInstructions(directory)
19
- const pathInstructions = loadPathInstructions(directory)
20
-
21
- if (repoInstructions) {
22
- console.log("[copilot-instructions] Loaded repo instructions from .github/copilot-instructions.md")
23
- }
24
- for (const instruction of pathInstructions) {
25
- console.log(`[copilot-instructions] Loaded path instructions from ${path.basename(instruction.file)}`)
26
- }
27
- if (!repoInstructions && pathInstructions.length === 0) {
28
- console.log("[copilot-instructions] No Copilot instructions found")
29
- }
30
-
31
- const state = new SessionState()
32
-
33
- const injectRepoInstructions = (event: { system: Array<{ type: string; text: string }> }) => {
34
- if (!repoInstructions) return
52
+ const state = new SessionState();
53
+
54
+ const getSessionDirectory = async (
55
+ sessionID: string,
56
+ ): Promise<string | null> => {
57
+ try {
58
+ const session = await ctx.session.get({ sessionID });
59
+ return session.location.directory;
60
+ } catch {
61
+ return null;
62
+ }
63
+ };
64
+
65
+ const injectRepoInstructions = async (event: {
66
+ sessionID: string;
67
+ system: Array<{ type: string; text: string }>;
68
+ }) => {
69
+ const directory = await getSessionDirectory(event.sessionID);
70
+ if (!directory) return;
71
+ const repoInstructions = loadRepoInstructions(findRepoRoot(directory));
72
+ if (!repoInstructions) return;
35
73
  event.system.push({
36
74
  type: "text",
37
75
  text: `<copilot-instruction:copilot-instructions.md>\n${repoInstructions.trimEnd()}\n</copilot-instruction:copilot-instructions.md>`,
38
- })
39
- }
76
+ });
77
+ };
40
78
 
41
79
  // Inject repo-wide instructions into every model call so they survive compaction.
42
- await ctx.session.hook("context", injectRepoInstructions)
43
- await ctx.session.hook("compaction", injectRepoInstructions)
80
+ await ctx.session.hook("context", injectRepoInstructions);
81
+ await ctx.session.hook("compaction", injectRepoInstructions);
44
82
 
45
- await ctx.tool.hook("execute.before", (event) => {
46
- if (!FILE_TOOLS.has(event.tool)) return
83
+ await ctx.tool.hook("execute.before", async (event) => {
84
+ if (!FILE_TOOLS.has(event.tool)) return;
47
85
 
48
- const input = event.input as { path?: unknown }
49
- const filePath = input?.path
50
- if (!filePath || typeof filePath !== "string") return
86
+ const input = event.input as { path?: unknown };
87
+ const filePath = input?.path;
88
+ if (!filePath || typeof filePath !== "string") return;
51
89
 
52
- const relativePath = getRelativePath(directory, filePath)
53
- const matching = pathInstructions.filter((instruction) => {
54
- if (state.isFileInjected(event.sessionID, instruction.file)) return false
55
- return instruction.matcher(relativePath)
56
- })
57
- if (matching.length === 0) return
90
+ const directory = await getSessionDirectory(event.sessionID);
91
+ if (!directory) return;
92
+ const repoRoot = findRepoRoot(directory);
93
+ const relativePath = getRepoRelativePath(repoRoot, directory, filePath);
94
+ if (relativePath === null) return;
58
95
 
59
- for (const instruction of matching) state.markFileInjected(event.sessionID, instruction.file)
96
+ const matching = loadPathInstructions(repoRoot).filter((instruction) => {
97
+ if (state.isFileInjected(event.sessionID, instruction.file))
98
+ return false;
99
+ return instruction.matcher(relativePath);
100
+ });
101
+ if (matching.length === 0) return;
102
+
103
+ for (const instruction of matching)
104
+ state.markFileInjected(event.sessionID, instruction.file);
60
105
 
61
106
  const text = matching
62
107
  .map((instruction) => {
63
- const filename = path.basename(instruction.file)
64
- const patterns = instruction.applyTo.join(", ")
65
- return `<copilot-instruction:${filename}>\n## Path-Specific Instructions (applies to: ${patterns})\n\n${instruction.content.trimEnd()}\n</copilot-instruction:${filename}>`
108
+ const filename = path.basename(instruction.file);
109
+ const patterns = instruction.applyTo.join(", ");
110
+ return `<copilot-instruction:${filename}>\n## Path-Specific Instructions (applies to: ${patterns})\n\n${instruction.content.trimEnd()}\n</copilot-instruction:${filename}>`;
66
111
  })
67
- .join("\n\n")
112
+ .join("\n\n");
68
113
 
69
- state.setPending(event.id, text)
70
- })
114
+ state.setPending(event.id, text);
115
+ });
71
116
 
72
117
  await ctx.tool.hook("execute.after", (event) => {
73
- const text = state.consumePending(event.id)
74
- if (!text) return
75
- if (event.status !== "completed") return
118
+ const text = state.consumePending(event.id);
119
+ if (!text) return;
120
+ if (event.status !== "completed") return;
76
121
 
77
- const existing = typeof event.result.content === "string" ? event.result.content : ""
78
122
  event.result = {
79
123
  ...event.result,
80
- content: existing ? `${existing}\n\n${text}` : text,
81
- }
82
- })
124
+ content: appendInstructions(event.result, text),
125
+ };
126
+ });
83
127
 
84
- const controller = new AbortController()
128
+ const controller = new AbortController();
85
129
  void (async () => {
86
- for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
130
+ for await (const event of ctx.event.subscribe({
131
+ signal: controller.signal,
132
+ })) {
87
133
  if (event.type === "session.compaction.ended") {
88
- state.clearSession(event.data.sessionID)
134
+ state.clearSession(event.data.sessionID);
89
135
  }
90
136
  }
91
- })()
137
+ })();
92
138
 
93
- return () => controller.abort()
139
+ return () => controller.abort();
94
140
  },
95
- })
141
+ });
package/src/loader.ts CHANGED
@@ -1,57 +1,68 @@
1
- import * as fs from "node:fs"
2
- import * as path from "node:path"
3
- import { parseFrontmatter } from "./frontmatter"
4
- import { createMatcher, normalizePatterns, type Matcher } from "./matcher"
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { parseFrontmatter } from "./frontmatter";
4
+ import { createMatcher, type Matcher, normalizePatterns } from "./matcher";
5
5
 
6
6
  export interface PathInstruction {
7
- file: string
8
- applyTo: string[]
9
- content: string
10
- matcher: Matcher
7
+ file: string;
8
+ applyTo: string[];
9
+ content: string;
10
+ matcher: Matcher;
11
+ }
12
+
13
+ /** Find the nearest ancestor containing `.git`, or return the directory itself. */
14
+ export function findRepoRoot(directory: string): string {
15
+ let current = path.resolve(directory);
16
+ while (true) {
17
+ if (fs.existsSync(path.join(current, ".git"))) return current;
18
+ const parent = path.dirname(current);
19
+ if (parent === current) return path.resolve(directory);
20
+ current = parent;
21
+ }
11
22
  }
12
23
 
13
24
  /** Load repo-wide instructions from .github/copilot-instructions.md */
14
25
  export function loadRepoInstructions(directory: string): string | null {
15
- const filePath = path.join(directory, ".github", "copilot-instructions.md")
26
+ const filePath = path.join(directory, ".github", "copilot-instructions.md");
16
27
  try {
17
- return fs.readFileSync(filePath, "utf-8")
28
+ return fs.readFileSync(filePath, "utf-8");
18
29
  } catch {
19
- return null
30
+ return null;
20
31
  }
21
32
  }
22
33
 
23
34
  /** Load path-specific instructions from .github/instructions/*.instructions.md */
24
35
  export function loadPathInstructions(directory: string): PathInstruction[] {
25
- const instructionsDir = path.join(directory, ".github", "instructions")
26
- let files: string[]
36
+ const instructionsDir = path.join(directory, ".github", "instructions");
37
+ let files: string[];
27
38
  try {
28
- files = fs.readdirSync(instructionsDir)
39
+ files = fs.readdirSync(instructionsDir);
29
40
  } catch {
30
- return []
41
+ return [];
31
42
  }
32
43
 
33
- const result: PathInstruction[] = []
44
+ const result: PathInstruction[] = [];
34
45
  for (const filename of files) {
35
- if (!filename.endsWith(".instructions.md")) continue
46
+ if (!filename.endsWith(".instructions.md")) continue;
36
47
 
37
- const filePath = path.join(instructionsDir, filename)
38
- let content: string
48
+ const filePath = path.join(instructionsDir, filename);
49
+ let content: string;
39
50
  try {
40
- content = fs.readFileSync(filePath, "utf-8")
51
+ content = fs.readFileSync(filePath, "utf-8");
41
52
  } catch {
42
- continue
53
+ continue;
43
54
  }
44
55
 
45
- const parsed = parseFrontmatter(content)
46
- const patterns = normalizePatterns(parsed.frontmatter.applyTo)
47
- if (patterns.length === 0) continue
56
+ const parsed = parseFrontmatter(content);
57
+ const patterns = normalizePatterns(parsed.frontmatter.applyTo);
58
+ if (patterns.length === 0) continue;
48
59
 
49
60
  result.push({
50
61
  file: filePath,
51
62
  applyTo: patterns,
52
63
  content: parsed.body,
53
64
  matcher: createMatcher(patterns),
54
- })
65
+ });
55
66
  }
56
- return result
67
+ return result;
57
68
  }
package/src/matcher.ts CHANGED
@@ -1,18 +1,20 @@
1
- import picomatch from "picomatch"
1
+ import picomatch from "picomatch";
2
2
 
3
- export type Matcher = (path: string) => boolean
3
+ export type Matcher = (path: string) => boolean;
4
4
 
5
5
  export function createMatcher(patterns: string[]): Matcher {
6
- if (patterns.length === 0) return () => false
7
- const isMatch = picomatch(patterns)
8
- return (path) => isMatch(path)
6
+ if (patterns.length === 0) return () => false;
7
+ const isMatch = picomatch(patterns);
8
+ return (path) => isMatch(path);
9
9
  }
10
10
 
11
- export function normalizePatterns(applyTo: string | string[] | undefined): string[] {
12
- if (applyTo === undefined) return []
13
- if (Array.isArray(applyTo)) return applyTo
11
+ export function normalizePatterns(
12
+ applyTo: string | string[] | undefined,
13
+ ): string[] {
14
+ if (applyTo === undefined) return [];
15
+ if (Array.isArray(applyTo)) return applyTo;
14
16
  return applyTo
15
17
  .split(",")
16
18
  .map((pattern) => pattern.trim())
17
- .filter((pattern) => pattern.length > 0)
19
+ .filter((pattern) => pattern.length > 0);
18
20
  }
@@ -5,33 +5,33 @@
5
5
  * pending instructions keyed by tool call ID.
6
6
  */
7
7
  export class SessionState {
8
- private injectedPerSession = new Map<string, Set<string>>()
9
- private pendingInstructions = new Map<string, string>()
8
+ private injectedPerSession = new Map<string, Set<string>>();
9
+ private pendingInstructions = new Map<string, string>();
10
10
 
11
11
  isFileInjected(sessionId: string, file: string): boolean {
12
- return this.injectedPerSession.get(sessionId)?.has(file) ?? false
12
+ return this.injectedPerSession.get(sessionId)?.has(file) ?? false;
13
13
  }
14
14
 
15
15
  markFileInjected(sessionId: string, file: string): void {
16
- let files = this.injectedPerSession.get(sessionId)
16
+ let files = this.injectedPerSession.get(sessionId);
17
17
  if (!files) {
18
- files = new Set()
19
- this.injectedPerSession.set(sessionId, files)
18
+ files = new Set();
19
+ this.injectedPerSession.set(sessionId, files);
20
20
  }
21
- files.add(file)
21
+ files.add(file);
22
22
  }
23
23
 
24
24
  clearSession(sessionId: string): void {
25
- this.injectedPerSession.delete(sessionId)
25
+ this.injectedPerSession.delete(sessionId);
26
26
  }
27
27
 
28
28
  setPending(callId: string, text: string): void {
29
- this.pendingInstructions.set(callId, text)
29
+ this.pendingInstructions.set(callId, text);
30
30
  }
31
31
 
32
32
  consumePending(callId: string): string | undefined {
33
- const text = this.pendingInstructions.get(callId)
34
- this.pendingInstructions.delete(callId)
35
- return text
33
+ const text = this.pendingInstructions.get(callId);
34
+ this.pendingInstructions.delete(callId);
35
+ return text;
36
36
  }
37
37
  }