@astudioplus/compressor 0.1.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.
Files changed (108) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +20 -0
  3. package/README.md +167 -0
  4. package/dist/adapters/agents-md.d.ts +2 -0
  5. package/dist/adapters/agents-md.js +91 -0
  6. package/dist/adapters/apply.d.ts +3 -0
  7. package/dist/adapters/apply.js +83 -0
  8. package/dist/adapters/claude-code.d.ts +2 -0
  9. package/dist/adapters/claude-code.js +403 -0
  10. package/dist/adapters/copilot.d.ts +2 -0
  11. package/dist/adapters/copilot.js +418 -0
  12. package/dist/adapters/cursor.d.ts +2 -0
  13. package/dist/adapters/cursor.js +149 -0
  14. package/dist/adapters/index.d.ts +11 -0
  15. package/dist/adapters/index.js +19 -0
  16. package/dist/adapters/markers.d.ts +7 -0
  17. package/dist/adapters/markers.js +129 -0
  18. package/dist/adapters/types.d.ts +44 -0
  19. package/dist/adapters/types.js +1 -0
  20. package/dist/bench/ablate.d.ts +35 -0
  21. package/dist/bench/ablate.js +163 -0
  22. package/dist/bench/cell.d.ts +33 -0
  23. package/dist/bench/cell.js +437 -0
  24. package/dist/bench/results.d.ts +37 -0
  25. package/dist/bench/results.js +157 -0
  26. package/dist/bench/runner.d.ts +24 -0
  27. package/dist/bench/runner.js +121 -0
  28. package/dist/bench/tasks.d.ts +4 -0
  29. package/dist/bench/tasks.js +147 -0
  30. package/dist/bench/types.d.ts +109 -0
  31. package/dist/bench/types.js +1 -0
  32. package/dist/claude/transcripts.d.ts +30 -0
  33. package/dist/claude/transcripts.js +154 -0
  34. package/dist/cli/commands/benchmark.d.ts +33 -0
  35. package/dist/cli/commands/benchmark.js +203 -0
  36. package/dist/cli/commands/compress.d.ts +8 -0
  37. package/dist/cli/commands/compress.js +45 -0
  38. package/dist/cli/commands/count.d.ts +5 -0
  39. package/dist/cli/commands/count.js +25 -0
  40. package/dist/cli/commands/hook.d.ts +6 -0
  41. package/dist/cli/commands/hook.js +30 -0
  42. package/dist/cli/commands/init.d.ts +16 -0
  43. package/dist/cli/commands/init.js +76 -0
  44. package/dist/cli/commands/report.d.ts +90 -0
  45. package/dist/cli/commands/report.js +464 -0
  46. package/dist/cli/commands/savings.d.ts +38 -0
  47. package/dist/cli/commands/savings.js +196 -0
  48. package/dist/cli/commands/set-mode.d.ts +5 -0
  49. package/dist/cli/commands/set-mode.js +13 -0
  50. package/dist/cli/commands/stats.d.ts +5 -0
  51. package/dist/cli/commands/stats.js +51 -0
  52. package/dist/cli/commands/status.d.ts +1 -0
  53. package/dist/cli/commands/status.js +11 -0
  54. package/dist/cli/commands/uninstall.d.ts +7 -0
  55. package/dist/cli/commands/uninstall.js +22 -0
  56. package/dist/cli/index.d.ts +2 -0
  57. package/dist/cli/index.js +146 -0
  58. package/dist/copilot-hook-entry.d.ts +1 -0
  59. package/dist/copilot-hook-entry.js +36 -0
  60. package/dist/copilot-hook.js +1000 -0
  61. package/dist/engine/detect.d.ts +2 -0
  62. package/dist/engine/detect.js +47 -0
  63. package/dist/engine/index.d.ts +4 -0
  64. package/dist/engine/index.js +90 -0
  65. package/dist/engine/policy.d.ts +2 -0
  66. package/dist/engine/policy.js +48 -0
  67. package/dist/engine/tiers/code.d.ts +7 -0
  68. package/dist/engine/tiers/code.js +206 -0
  69. package/dist/engine/tiers/logs.d.ts +4 -0
  70. package/dist/engine/tiers/logs.js +139 -0
  71. package/dist/engine/tiers/structural.d.ts +28 -0
  72. package/dist/engine/tiers/structural.js +199 -0
  73. package/dist/engine/types.d.ts +71 -0
  74. package/dist/engine/types.js +5 -0
  75. package/dist/hook/copilot.d.ts +5 -0
  76. package/dist/hook/copilot.js +136 -0
  77. package/dist/hook/core.d.ts +36 -0
  78. package/dist/hook/core.js +138 -0
  79. package/dist/hook/exit.d.ts +22 -0
  80. package/dist/hook/exit.js +56 -0
  81. package/dist/hook/post-tool-use.d.ts +5 -0
  82. package/dist/hook/post-tool-use.js +57 -0
  83. package/dist/hook-entry.d.ts +1 -0
  84. package/dist/hook-entry.js +35 -0
  85. package/dist/hook.js +946 -0
  86. package/dist/index.d.ts +15 -0
  87. package/dist/index.js +16 -0
  88. package/dist/ledger/read.d.ts +9 -0
  89. package/dist/ledger/read.js +91 -0
  90. package/dist/ledger/write.d.ts +29 -0
  91. package/dist/ledger/write.js +61 -0
  92. package/dist/packs/atoms.d.ts +3 -0
  93. package/dist/packs/atoms.js +108 -0
  94. package/dist/packs/modes.d.ts +3 -0
  95. package/dist/packs/modes.js +34 -0
  96. package/dist/packs/render.d.ts +24 -0
  97. package/dist/packs/render.js +115 -0
  98. package/dist/packs/types.d.ts +32 -0
  99. package/dist/packs/types.js +1 -0
  100. package/dist/paths.d.ts +29 -0
  101. package/dist/paths.js +87 -0
  102. package/dist/tokens/estimate.d.ts +12 -0
  103. package/dist/tokens/estimate.js +23 -0
  104. package/dist/tokens/exact.d.ts +5 -0
  105. package/dist/tokens/exact.js +16 -0
  106. package/dist/tokens/index.d.ts +2 -0
  107. package/dist/tokens/index.js +2 -0
  108. package/package.json +77 -0
@@ -0,0 +1,418 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { parseAtomManifest, renderMarkedSection } from "../packs/render.js";
5
+ import { copilotHookCommandFrom } from "../paths.js";
6
+ import { readMarkedSection, removeMarkedSection, upsertMarkedSection, } from "./markers.js";
7
+ // Copilot reads .github/copilot-instructions.md for instructions, and Copilot
8
+ // CLI + cloud agent run command hooks from .github/hooks/*.json. Our
9
+ // postToolUse entry replaces tool results via modifiedResult.textResultForLlm
10
+ // — the same input-compression mechanism as the Claude Code PostToolUse hook.
11
+ //
12
+ // Honesty notes baked into status:
13
+ // - hooks run in Copilot CLI and cloud agent ONLY; the VS Code/IDE surface
14
+ // does not execute these hook files (instructions still apply there)
15
+ // - the installed command is `node "<abs path>/dist/copilot-hook.js" ...` —
16
+ // an absolute path on THIS machine. The cloud agent's Linux sandbox (and
17
+ // any teammate's clone of a committed .github/hooks/compressor.json)
18
+ // cannot run it, so compression is effective only in Copilot CLI on the
19
+ // installing machine; elsewhere the entry is a dead command that degrades
20
+ // to a logged fail-open no-op (postToolUse never blocks the agent).
21
+ // Status must not imply a cloud-agent benefit.
22
+ // - user-scope hooks (<copilotHome>/hooks/, CLI >= 1.0.21; copilotHome is
23
+ // $COPILOT_HOME when set, else ~/.copilot) load in Copilot CLI only. Global
24
+ // install therefore plans ONLY the hook config there — instructions have no
25
+ // user-global mechanism at all (personal instructions are a github.com web
26
+ // setting), the IDE runs no hook files, and the cloud agent reads only
27
+ // .github/hooks/ on the default branch.
28
+ const HOOK_SURFACES_NOTE = 'compression effective in Copilot CLI on this machine only — the hook command is an absolute local path (a fail-open no-op for cloud agent and teammates; the IDE runs no hook files)';
29
+ const HOOK_MISSING_NOTE = 'instructions only — compression hook not installed (.github/hooks/compressor.json)';
30
+ const AGENTS_MD_OVERLAP_NOTE = 'NOTE: Copilot also reads AGENTS.md — both installed means duplicated instructions';
31
+ const GLOBAL_HOOK_NOTE = 'machine-wide input compression for Copilot CLI on this machine (instructions are per-repo; IDE runs no hook files; cloud agent reads only .github/hooks on the default branch)';
32
+ const GLOBAL_CROSS_NOTE = 'installed globally (machine-wide hook)';
33
+ /** Our hook config file under .github/hooks/ (any NAME.json is valid). */
34
+ const HOOK_CONFIG_FILE = 'compressor.json';
35
+ /**
36
+ * postToolUse accepts no matcher, so this command runs after EVERY successful
37
+ * tool call. Compression is local CPU only; cap well below the 30s default so
38
+ * a wedged node process cannot stall the agent. Non-zero/timeout is fail-open.
39
+ */
40
+ const HOOK_TIMEOUT_SEC = 10;
41
+ function instructionsPath(ctx) {
42
+ return path.join(ctx.projectDir, '.github', 'copilot-instructions.md');
43
+ }
44
+ function hookConfigPath(ctx) {
45
+ return path.join(ctx.projectDir, '.github', 'hooks', HOOK_CONFIG_FILE);
46
+ }
47
+ /**
48
+ * The effective $COPILOT_HOME override, or null when unset (whitespace-only
49
+ * counts as unset). Non-absolute values — including a literal `~`, which Node
50
+ * never expands — are refused: every scope decision (detect/install/uninstall/
51
+ * status) routes through this value, and a relative path would anchor the
52
+ * "machine-wide" hook to whatever directory the command happened to run from
53
+ * (global-to-project scope leakage; an uninstall from any other cwd could
54
+ * never find the file again).
55
+ */
56
+ function copilotHomeOverride() {
57
+ const env = process.env['COPILOT_HOME'];
58
+ if (env === undefined || env.trim() === '') {
59
+ return null;
60
+ }
61
+ if (!path.isAbsolute(env)) {
62
+ throw new Error(`COPILOT_HOME=${JSON.stringify(env)} is not an absolute path — refusing to anchor the machine-wide hook to the current directory (~ is not expanded; set an absolute path or unset it, then re-run)`);
63
+ }
64
+ return env;
65
+ }
66
+ /** $COPILOT_HOME when set (Copilot CLI honors it), else ~/.copilot. */
67
+ function copilotHome(ctx) {
68
+ return copilotHomeOverride() ?? path.join(ctx.homeDir, '.copilot');
69
+ }
70
+ function globalHookConfigPath(ctx) {
71
+ return path.join(copilotHome(ctx), 'hooks', HOOK_CONFIG_FILE);
72
+ }
73
+ /** Human form of the global config location for status lines. */
74
+ function globalHookConfigDisplay() {
75
+ return copilotHomeOverride() !== null
76
+ ? `$COPILOT_HOME/hooks/${HOOK_CONFIG_FILE}`
77
+ : `~/.copilot/hooks/${HOOK_CONFIG_FILE}`;
78
+ }
79
+ function agentsMdPath(ctx) {
80
+ return path.join(ctx.projectDir, 'AGENTS.md');
81
+ }
82
+ function isErrnoException(error) {
83
+ return error instanceof Error && 'code' in error;
84
+ }
85
+ async function readFileOrNull(filePath) {
86
+ try {
87
+ return await readFile(filePath, 'utf8');
88
+ }
89
+ catch (error) {
90
+ if (isErrnoException(error) && error.code === 'ENOENT') {
91
+ return null;
92
+ }
93
+ throw error;
94
+ }
95
+ }
96
+ async function dirExists(dirPath) {
97
+ try {
98
+ return (await stat(dirPath)).isDirectory();
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
104
+ function asRecord(value) {
105
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
106
+ ? value
107
+ : null;
108
+ }
109
+ function asArray(value) {
110
+ return Array.isArray(value) ? value : null;
111
+ }
112
+ function notTouchingIt(what) {
113
+ return new Error(`${HOOK_CONFIG_FILE} ${what} — not touching it (fix or remove it, then re-run)`);
114
+ }
115
+ function parseHookConfig(text) {
116
+ if (text === null) {
117
+ return { version: 1 };
118
+ }
119
+ let parsed;
120
+ try {
121
+ parsed = JSON.parse(text);
122
+ }
123
+ catch {
124
+ throw notTouchingIt('is not valid JSON');
125
+ }
126
+ const record = asRecord(parsed);
127
+ if (record === null) {
128
+ throw notTouchingIt('is not valid JSON');
129
+ }
130
+ // A non-object "hooks" or non-array "postToolUse" (an easy hand-edit
131
+ // mistake) would otherwise be coerced to empty by mergeOurHook and written
132
+ // back as only our entry — silently destroying foreign data. Refuse, same
133
+ // as invalid JSON. (Foreign events keep whatever shape they have; merge
134
+ // never rewrites them.)
135
+ if (record['hooks'] !== undefined) {
136
+ const hooks = asRecord(record['hooks']);
137
+ if (hooks === null) {
138
+ throw notTouchingIt('has a non-object "hooks" value');
139
+ }
140
+ if (hooks['postToolUse'] !== undefined && asArray(hooks['postToolUse']) === null) {
141
+ throw notTouchingIt('has a non-array "hooks.postToolUse" value');
142
+ }
143
+ }
144
+ return { ...record };
145
+ }
146
+ function detectIndent(original) {
147
+ if (original === null) {
148
+ return ' ';
149
+ }
150
+ return /\n([ \t]+)"/.exec(original)?.[1] ?? ' ';
151
+ }
152
+ function serializeHookConfig(config, original) {
153
+ return `${JSON.stringify(config, null, detectIndent(original))}\n`;
154
+ }
155
+ function commandBase(command) {
156
+ return command.replace(/ --mode \S+$/, '');
157
+ }
158
+ /**
159
+ * Base forms we accept as ours: the current command (copilot-hook.js path
160
+ * quoted against spaces) and an unquoted variant (legacy tolerance, mirroring
161
+ * the claude-code predicate).
162
+ */
163
+ function ourBases(ourCommand) {
164
+ const base = commandBase(ourCommand);
165
+ const unquoted = base.replaceAll('"', '');
166
+ return unquoted === base ? [base] : [base, unquoted];
167
+ }
168
+ function commandIsOurs(command, ourCommand) {
169
+ if (typeof command !== 'string') {
170
+ return false;
171
+ }
172
+ return (command === ourCommand ||
173
+ ourBases(ourCommand).some((base) => command === base || command.startsWith(`${base} --mode `)));
174
+ }
175
+ /**
176
+ * Ownership predicate: exact match on our resolved copilot-hook command in
177
+ * the entry's bash or powershell field, allowing a different --mode value
178
+ * (mode switches rewrite the flag; the absolute path identifies us). Generic
179
+ * substrings like 'dist/copilot-hook.js' are NOT ours.
180
+ */
181
+ function isOurHookEntry(entry, ourCommand) {
182
+ const record = asRecord(entry);
183
+ if (record === null) {
184
+ return false;
185
+ }
186
+ return (commandIsOurs(record['bash'], ourCommand) ||
187
+ commandIsOurs(record['powershell'], ourCommand));
188
+ }
189
+ function hookEntryFor(command) {
190
+ // both platform keys per the reference docs; node invocation is identical
191
+ return {
192
+ type: 'command',
193
+ bash: command,
194
+ powershell: command,
195
+ timeoutSec: HOOK_TIMEOUT_SEC,
196
+ };
197
+ }
198
+ /** Replace our postToolUse entry (or append it), preserving foreign entries and events. */
199
+ function mergeOurHook(config, command) {
200
+ const hooks = { ...(asRecord(config['hooks']) ?? {}) };
201
+ const post = asArray(hooks['postToolUse']) ?? [];
202
+ const ourEntry = hookEntryFor(command);
203
+ const next = [];
204
+ let replaced = false;
205
+ for (const entry of post) {
206
+ if (isOurHookEntry(entry, command)) {
207
+ if (!replaced) {
208
+ next.push(ourEntry);
209
+ replaced = true;
210
+ }
211
+ }
212
+ else {
213
+ next.push(entry);
214
+ }
215
+ }
216
+ if (!replaced) {
217
+ next.push(ourEntry);
218
+ }
219
+ hooks['postToolUse'] = next;
220
+ config['hooks'] = hooks;
221
+ if (config['version'] === undefined) {
222
+ config['version'] = 1;
223
+ }
224
+ }
225
+ /** Remove only our entries. Returns true when something was removed. */
226
+ function stripOurHook(config, command) {
227
+ const hooks = asRecord(config['hooks']);
228
+ const post = asArray(hooks?.['postToolUse']);
229
+ if (hooks === null || post === null) {
230
+ return false;
231
+ }
232
+ const kept = post.filter((entry) => !isOurHookEntry(entry, command));
233
+ if (kept.length === post.length) {
234
+ return false;
235
+ }
236
+ const next = { ...hooks };
237
+ if (kept.length === 0) {
238
+ delete next['postToolUse'];
239
+ }
240
+ else {
241
+ next['postToolUse'] = kept;
242
+ }
243
+ if (Object.keys(next).length === 0) {
244
+ delete config['hooks'];
245
+ }
246
+ else {
247
+ config['hooks'] = next;
248
+ }
249
+ return true;
250
+ }
251
+ /** Nothing left but the schema version ⇒ the file carries no information. */
252
+ function onlyVersionLeft(config) {
253
+ return Object.keys(config).every((key) => key === 'version');
254
+ }
255
+ /**
256
+ * Plan merging our entry into the hook config at `filePath` (project or
257
+ * global scope — the merge/ownership/foreign-preservation rules are
258
+ * identical; only the path differs).
259
+ */
260
+ async function planHookConfigInstall(filePath, command) {
261
+ const before = await readFileOrNull(filePath);
262
+ const config = parseHookConfig(before);
263
+ mergeOurHook(config, command);
264
+ return { path: filePath, before, after: serializeHookConfig(config, before) };
265
+ }
266
+ /**
267
+ * Plan stripping our entry from the hook config at `filePath`. Returns null
268
+ * when there is nothing of ours to remove. compressor.json is our namespaced
269
+ * file: once only the version stub remains it is safe to delete; foreign
270
+ * entries/events keep it alive.
271
+ */
272
+ async function planHookConfigUninstall(filePath, ourCommand) {
273
+ const before = await readFileOrNull(filePath);
274
+ if (before === null) {
275
+ return null;
276
+ }
277
+ const config = parseHookConfig(before);
278
+ if (!stripOurHook(config, ourCommand)) {
279
+ return null;
280
+ }
281
+ const after = onlyVersionLeft(config)
282
+ ? null
283
+ : serializeHookConfig(config, before);
284
+ return { path: filePath, before, after };
285
+ }
286
+ async function inspectHookAt(filePath, ourCommand) {
287
+ const text = await readFileOrNull(filePath);
288
+ if (text === null) {
289
+ return { present: false, mode: null };
290
+ }
291
+ let config = null;
292
+ try {
293
+ config = parseHookConfig(text);
294
+ }
295
+ catch {
296
+ config = null; // status never throws on a broken file
297
+ }
298
+ const post = asArray(asRecord(config?.['hooks'])?.['postToolUse']);
299
+ const ours = post?.find((entry) => isOurHookEntry(entry, ourCommand));
300
+ if (ours === undefined) {
301
+ return { present: false, mode: null };
302
+ }
303
+ const bash = asRecord(ours)?.['bash'];
304
+ const flag = typeof bash === 'string' ? / --mode (\S+)$/.exec(bash)?.[1] : undefined;
305
+ return {
306
+ present: true,
307
+ mode: flag === 'optimized' || flag === 'slim' ? flag : null,
308
+ };
309
+ }
310
+ export const copilotAdapter = {
311
+ name: 'copilot',
312
+ async detect(ctx) {
313
+ if (ctx.global) {
314
+ return dirExists(copilotHome(ctx));
315
+ }
316
+ return dirExists(path.join(ctx.projectDir, '.github'));
317
+ },
318
+ async install(mode, ctx) {
319
+ const command = copilotHookCommandFrom(ctx.hookCommand, mode);
320
+ if (ctx.global) {
321
+ // user-scope hooks load in Copilot CLI only; instructions have no
322
+ // user-global mechanism, so global plans ONLY the hook config — never
323
+ // an instructions change.
324
+ const change = await planHookConfigInstall(globalHookConfigPath(ctx), command);
325
+ return change.before === change.after ? [] : [change];
326
+ }
327
+ const changes = [];
328
+ const file = instructionsPath(ctx);
329
+ const before = await readFileOrNull(file);
330
+ const after = upsertMarkedSection(before, renderMarkedSection(mode, 'copilot').body);
331
+ changes.push({ path: file, before, after });
332
+ changes.push(await planHookConfigInstall(hookConfigPath(ctx), command));
333
+ return changes.filter((change) => change.before !== change.after);
334
+ },
335
+ async uninstall(ctx) {
336
+ const ourCommand = copilotHookCommandFrom(ctx.hookCommand);
337
+ if (ctx.global) {
338
+ const change = await planHookConfigUninstall(globalHookConfigPath(ctx), ourCommand);
339
+ return change === null ? [] : [change];
340
+ }
341
+ const changes = [];
342
+ const file = instructionsPath(ctx);
343
+ const before = await readFileOrNull(file);
344
+ if (before !== null && readMarkedSection(before) !== null) {
345
+ // Never delete the file: whether WE created it is not derivable from
346
+ // disk (a user-created empty file that received our section is
347
+ // byte-identical to one we created), so err KEEP — worst case an empty
348
+ // file remains. Matches the cursor .cursorrules precedent.
349
+ changes.push({ path: file, before, after: removeMarkedSection(before) });
350
+ }
351
+ const hookChange = await planHookConfigUninstall(hookConfigPath(ctx), ourCommand);
352
+ if (hookChange !== null) {
353
+ changes.push(hookChange);
354
+ }
355
+ return changes;
356
+ },
357
+ async status(ctx) {
358
+ const ourCommand = copilotHookCommandFrom(ctx.hookCommand);
359
+ const globalHook = await inspectHookAt(globalHookConfigPath(ctx), ourCommand);
360
+ if (ctx.global) {
361
+ if (!globalHook.present) {
362
+ return { agent: 'copilot', installed: false, detail: 'not installed' };
363
+ }
364
+ return {
365
+ agent: 'copilot',
366
+ installed: true,
367
+ ...(globalHook.mode !== null ? { mode: globalHook.mode } : {}),
368
+ detail: `${globalHookConfigDisplay()} (global) — ${GLOBAL_HOOK_NOTE}`,
369
+ };
370
+ }
371
+ const body = await readFileOrNull(instructionsPath(ctx));
372
+ const section = body === null ? null : readMarkedSection(body);
373
+ const hook = await inspectHookAt(hookConfigPath(ctx), ourCommand);
374
+ if (section === null && !hook.present) {
375
+ if (globalHook.present) {
376
+ // scope-faithful: nothing at project level, but the machine-wide
377
+ // hook still compresses Copilot CLI input here (claude-code's
378
+ // cross-scope note pattern)
379
+ return {
380
+ agent: 'copilot',
381
+ installed: true,
382
+ ...(globalHook.mode !== null ? { mode: globalHook.mode } : {}),
383
+ detail: `not installed (project); ${GLOBAL_CROSS_NOTE}`,
384
+ };
385
+ }
386
+ return { agent: 'copilot', installed: false, detail: 'not installed' };
387
+ }
388
+ const mode = (section === null ? undefined : parseAtomManifest(section)?.mode) ??
389
+ hook.mode ??
390
+ globalHook.mode ??
391
+ undefined;
392
+ let detail;
393
+ if (section !== null && hook.present) {
394
+ detail = `.github/copilot-instructions.md section + .github/hooks/${HOOK_CONFIG_FILE} (project) — instructions + input compression (Copilot hooks); ${HOOK_SURFACES_NOTE}`;
395
+ }
396
+ else if (section !== null) {
397
+ detail = `.github/copilot-instructions.md section (project) — ${HOOK_MISSING_NOTE}`;
398
+ }
399
+ else {
400
+ detail = `.github/hooks/${HOOK_CONFIG_FILE} (project) — input compression only, instructions not installed; ${HOOK_SURFACES_NOTE}`;
401
+ }
402
+ if (section !== null) {
403
+ const agentsMd = await readFileOrNull(agentsMdPath(ctx));
404
+ if (agentsMd !== null && readMarkedSection(agentsMd) !== null) {
405
+ detail += `; ${AGENTS_MD_OVERLAP_NOTE}`;
406
+ }
407
+ }
408
+ if (globalHook.present) {
409
+ detail += `; also ${GLOBAL_CROSS_NOTE}`;
410
+ }
411
+ return {
412
+ agent: 'copilot',
413
+ installed: true,
414
+ ...(mode !== undefined ? { mode } : {}),
415
+ detail,
416
+ };
417
+ },
418
+ };
@@ -0,0 +1,2 @@
1
+ import type { Adapter } from './types.ts';
2
+ export declare const cursorAdapter: Adapter;
@@ -0,0 +1,149 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { parseAtomManifest, renderCursorRules, renderMarkedSection, } from "../packs/render.js";
4
+ import { readMarkedSection, removeMarkedSection, upsertMarkedSection, } from "./markers.js";
5
+ // Cursor's rules system reads .cursor/rules/*.mdc — a plain .md file there is
6
+ // silently ignored, and frontmatter is mandatory (renderCursorRules emits
7
+ // description + alwaysApply: true; with alwaysApply: true the other fields are
8
+ // ignored by Cursor, but description doubles as our mode manifest). Cursor
9
+ // ships a stable hooks system (sessionStart, preToolUse/postToolUse,
10
+ // beforeReadFile, ...), but postToolUse can replace output for MCP tools only
11
+ // (updated_mcp_tool_output) and beforeReadFile is permission-only, so
12
+ // compressor-style rewriting of built-in Read/Shell output is not currently
13
+ // possible — only the instruction half of compressor applies.
14
+ const ASYMMETRY_NOTE = 'instructions only — Cursor hooks cannot rewrite built-in tool output (postToolUse replaces MCP output only)';
15
+ const MODIFIED_NOTE = 'locally modified — install will overwrite';
16
+ const AGENTS_MD_OVERLAP_NOTE = 'NOTE: Cursor also reads AGENTS.md — both installed means duplicated instructions';
17
+ function mdcPath(ctx) {
18
+ return path.join(ctx.projectDir, '.cursor', 'rules', 'compressor.mdc');
19
+ }
20
+ // Deprecated (dropped from Cursor's docs) but still read for back-compat:
21
+ // if the user already drives Cursor through it we upsert a marked section
22
+ // there too. Never created by us.
23
+ function legacyRulesPath(ctx) {
24
+ return path.join(ctx.projectDir, '.cursorrules');
25
+ }
26
+ function agentsMdPath(ctx) {
27
+ return path.join(ctx.projectDir, 'AGENTS.md');
28
+ }
29
+ function isErrnoException(error) {
30
+ return error instanceof Error && 'code' in error;
31
+ }
32
+ async function readFileOrNull(filePath) {
33
+ try {
34
+ return await readFile(filePath, 'utf8');
35
+ }
36
+ catch (error) {
37
+ if (isErrnoException(error) && error.code === 'ENOENT') {
38
+ return null;
39
+ }
40
+ throw error;
41
+ }
42
+ }
43
+ async function dirExists(dirPath) {
44
+ try {
45
+ return (await stat(dirPath)).isDirectory();
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ async function fileExists(filePath) {
52
+ try {
53
+ return (await stat(filePath)).isFile();
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ export const cursorAdapter = {
60
+ name: 'cursor',
61
+ async detect(ctx) {
62
+ return ((await dirExists(path.join(ctx.projectDir, '.cursor'))) ||
63
+ (await fileExists(legacyRulesPath(ctx))));
64
+ },
65
+ async install(mode, ctx) {
66
+ if (ctx.global) {
67
+ throw new Error('cursor: Cursor rules are per-project; use project scope');
68
+ }
69
+ const changes = [];
70
+ // compressor-owned file: overwrite wholesale, no markers needed
71
+ const mdcFile = mdcPath(ctx);
72
+ const mdcBefore = await readFileOrNull(mdcFile);
73
+ const mdcAfter = renderCursorRules(mode).body;
74
+ if (mdcAfter !== mdcBefore) {
75
+ changes.push({ path: mdcFile, before: mdcBefore, after: mdcAfter });
76
+ }
77
+ const legacyFile = legacyRulesPath(ctx);
78
+ const legacyBefore = await readFileOrNull(legacyFile);
79
+ if (legacyBefore !== null) {
80
+ const legacyAfter = upsertMarkedSection(legacyBefore, renderMarkedSection(mode, 'cursor').body);
81
+ if (legacyAfter !== legacyBefore) {
82
+ changes.push({ path: legacyFile, before: legacyBefore, after: legacyAfter });
83
+ }
84
+ }
85
+ return changes;
86
+ },
87
+ async uninstall(ctx) {
88
+ if (ctx.global) {
89
+ // install refuses global scope, so nothing of ours can exist there
90
+ return [];
91
+ }
92
+ const changes = [];
93
+ const mdcFile = mdcPath(ctx);
94
+ const mdcBefore = await readFileOrNull(mdcFile);
95
+ if (mdcBefore !== null) {
96
+ changes.push({ path: mdcFile, before: mdcBefore, after: null });
97
+ }
98
+ const legacyFile = legacyRulesPath(ctx);
99
+ const legacyBefore = await readFileOrNull(legacyFile);
100
+ if (legacyBefore !== null && readMarkedSection(legacyBefore) !== null) {
101
+ // never delete .cursorrules — we never create it, only sectioned it
102
+ changes.push({
103
+ path: legacyFile,
104
+ before: legacyBefore,
105
+ after: removeMarkedSection(legacyBefore),
106
+ });
107
+ }
108
+ return changes;
109
+ },
110
+ async status(ctx) {
111
+ const mdcBody = await readFileOrNull(mdcPath(ctx));
112
+ const legacyBody = await readFileOrNull(legacyRulesPath(ctx));
113
+ const legacySection = legacyBody === null ? null : readMarkedSection(legacyBody);
114
+ if (mdcBody === null && legacySection === null) {
115
+ return { agent: 'cursor', installed: false, detail: 'not installed' };
116
+ }
117
+ const mdcMode = mdcBody === null ? undefined : parseAtomManifest(mdcBody)?.mode;
118
+ let mode = mdcMode;
119
+ if (mode === undefined && legacySection !== null) {
120
+ mode = parseAtomManifest(legacySection)?.mode;
121
+ }
122
+ // Hand edits to the compressor-owned .mdc are otherwise invisible: install
123
+ // overwrites wholesale and uninstall deletes unconditionally, so surface
124
+ // any drift from what install would write (including a broken manifest).
125
+ const mdcModified = mdcBody !== null &&
126
+ (mdcMode === undefined || mdcBody !== renderCursorRules(mdcMode).body);
127
+ const parts = [];
128
+ if (mdcBody !== null) {
129
+ parts.push('.cursor/rules/compressor.mdc');
130
+ }
131
+ if (legacySection !== null) {
132
+ parts.push('legacy .cursorrules section');
133
+ }
134
+ let detail = `${parts.join(' + ')} (project) — ${ASYMMETRY_NOTE}`;
135
+ if (mdcModified) {
136
+ detail += `; ${MODIFIED_NOTE}`;
137
+ }
138
+ const agentsMd = await readFileOrNull(agentsMdPath(ctx));
139
+ if (agentsMd !== null && readMarkedSection(agentsMd) !== null) {
140
+ detail += `; ${AGENTS_MD_OVERLAP_NOTE}`;
141
+ }
142
+ return {
143
+ agent: 'cursor',
144
+ installed: true,
145
+ ...(mode !== undefined ? { mode } : {}),
146
+ detail,
147
+ };
148
+ },
149
+ };
@@ -0,0 +1,11 @@
1
+ import type { AgentName } from '../packs/types.ts';
2
+ import type { Adapter } from './types.ts';
3
+ export declare const adapters: Adapter[];
4
+ export declare function getAdapter(name: AgentName): Adapter | undefined;
5
+ export { claudeCodeAdapter } from './claude-code.ts';
6
+ export { copilotAdapter } from './copilot.ts';
7
+ export { cursorAdapter } from './cursor.ts';
8
+ export { agentsMdAdapter } from './agents-md.ts';
9
+ export { applyChanges, renderChanges } from './apply.ts';
10
+ export { upsertMarkedSection, removeMarkedSection, readMarkedSection, } from './markers.ts';
11
+ export type { Adapter, AdapterContext, AdapterStatus, FileChange, ModeArg, } from './types.ts';
@@ -0,0 +1,19 @@
1
+ import { claudeCodeAdapter } from "./claude-code.js";
2
+ import { copilotAdapter } from "./copilot.js";
3
+ import { cursorAdapter } from "./cursor.js";
4
+ import { agentsMdAdapter } from "./agents-md.js";
5
+ export const adapters = [
6
+ claudeCodeAdapter,
7
+ copilotAdapter,
8
+ cursorAdapter,
9
+ agentsMdAdapter,
10
+ ];
11
+ export function getAdapter(name) {
12
+ return adapters.find((adapter) => adapter.name === name);
13
+ }
14
+ export { claudeCodeAdapter } from "./claude-code.js";
15
+ export { copilotAdapter } from "./copilot.js";
16
+ export { cursorAdapter } from "./cursor.js";
17
+ export { agentsMdAdapter } from "./agents-md.js";
18
+ export { applyChanges, renderChanges } from "./apply.js";
19
+ export { upsertMarkedSection, removeMarkedSection, readMarkedSection, } from "./markers.js";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Replace the compressor section in place if present; else append with exactly
3
+ * one blank line of separation. Bytes outside the markers are never modified.
4
+ */
5
+ export declare function upsertMarkedSection(existing: string | null, section: string): string;
6
+ export declare function removeMarkedSection(existing: string): string;
7
+ export declare function readMarkedSection(existing: string): string | null;