memtrace-skills 1.2.9 → 1.2.11

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 (48) hide show
  1. package/dist/managed-skills.d.ts +4 -0
  2. package/dist/managed-skills.js +95 -0
  3. package/dist/rail-hook.d.ts +7 -1
  4. package/dist/rail-hook.js +177 -57
  5. package/dist/rail-install.js +3 -6
  6. package/dist/rail-lifecycle.js +6 -3
  7. package/dist/rail-runtime.d.ts +38 -3
  8. package/dist/rail-runtime.js +88 -7
  9. package/dist/tool-names.js +6 -0
  10. package/dist/transformers/claude.d.ts +16 -4
  11. package/dist/transformers/claude.js +69 -82
  12. package/dist/transformers/codex.js +5 -20
  13. package/dist/transformers/cursor.js +5 -20
  14. package/dist/transformers/kiro.js +5 -14
  15. package/dist/transformers/opencode.js +6 -6
  16. package/dist/transformers/pi.js +5 -5
  17. package/dist/transformers/rail-hooks.d.ts +6 -1
  18. package/dist/transformers/rail-hooks.js +72 -13
  19. package/dist/transformers/shared.d.ts +2 -1
  20. package/dist/transformers/shared.js +28 -23
  21. package/dist/transformers/vscode.js +3 -1
  22. package/package.json +2 -2
  23. package/plugins/memtrace-skills/references/mcp-parameters.md +14 -3
  24. package/plugins/memtrace-skills/skills/memtrace-codebase-exploration/SKILL.md +4 -0
  25. package/plugins/memtrace-skills/skills/memtrace-daily/SKILL.md +4 -0
  26. package/plugins/memtrace-skills/skills/memtrace-first/SKILL.md +55 -24
  27. package/plugins/memtrace-skills/skills/memtrace-graph/SKILL.md +4 -0
  28. package/plugins/memtrace-skills/skills/memtrace-guided-refactor/SKILL.md +88 -0
  29. package/plugins/memtrace-skills/skills/memtrace-impact/SKILL.md +4 -0
  30. package/plugins/memtrace-skills/skills/memtrace-index/SKILL.md +21 -0
  31. package/plugins/memtrace-skills/skills/memtrace-preflight/SKILL.md +4 -0
  32. package/plugins/memtrace-skills/skills/memtrace-quality/SKILL.md +16 -2
  33. package/plugins/memtrace-skills/skills/memtrace-refactoring-guide/SKILL.md +4 -0
  34. package/plugins/memtrace-skills/skills/memtrace-search/SKILL.md +1 -0
  35. package/plugins/memtrace-skills/skills/memtrace-session-continuity/SKILL.md +4 -0
  36. package/skills/commands/memtrace-daily.md +4 -0
  37. package/skills/commands/memtrace-graph.md +4 -0
  38. package/skills/commands/memtrace-impact.md +4 -0
  39. package/skills/commands/memtrace-index.md +22 -0
  40. package/skills/commands/memtrace-preflight.md +4 -0
  41. package/skills/commands/memtrace-quality.md +19 -2
  42. package/skills/commands/memtrace-search.md +1 -0
  43. package/skills/managed-stock-sha256.txt +364 -0
  44. package/skills/workflows/memtrace-codebase-exploration.md +4 -0
  45. package/skills/workflows/memtrace-first.md +55 -24
  46. package/skills/workflows/memtrace-guided-refactor.md +89 -0
  47. package/skills/workflows/memtrace-refactoring-guide.md +4 -0
  48. package/skills/workflows/memtrace-session-continuity.md +4 -0
@@ -19,7 +19,7 @@ export interface SettingsMutationResult {
19
19
  * Merge-add the memtrace MCP server into a Claude settings.json file.
20
20
  * Never overwrites a malformed file — backs it up and returns registered=false.
21
21
  */
22
- export declare function registerMcpInSettingsAt(settingsPath: string, memtraceBinary: string, legacyEnv?: Record<string, string>): SettingsMutationResult;
22
+ export declare function registerMcpInSettingsAt(settingsPath: string, memtraceBinary: string, legacyEnv?: Record<string, string>, legacyArgs?: string[]): SettingsMutationResult;
23
23
  /**
24
24
  * Every host enters a local liveness guard before contacting Memtrace. The
25
25
  * daemon lifecycle registers/removes this command; a cached registration is
@@ -32,8 +32,8 @@ export declare function claudeRailHookCommand(_memtraceBinary: string, platform?
32
32
  * This is what makes Rail "part of memtrace" rather than a manual install:
33
33
  * `memtrace install` wires a PreToolUse hook that pipes every Grep/Glob/Bash
34
34
  * attempt through the Rail router (see {@link claudeRailHookCommand} for the
35
- * transport). While a daemon is running the hook defaults to **observe** —
36
- * `MEMTRACE_RAIL=nudge|rail|strict` opts into more. Mode is intentionally NOT
35
+ * transport). While a daemon is running the hook defaults to **nudge** —
36
+ * context alongside ordinary tools. Explicit modes are preserved. Mode is intentionally NOT
37
37
  * baked into the command so the user can change it via env without
38
38
  * re-installing.
39
39
  *
@@ -41,7 +41,19 @@ export declare function claudeRailHookCommand(_memtraceBinary: string, platform?
41
41
  * is replaced, and unrelated PreToolUse hooks are preserved. Never overwrites
42
42
  * a malformed file.
43
43
  */
44
- export declare function registerRailHookInSettingsAt(settingsPath: string, memtraceBinary: string, platform?: NodeJS.Platform): SettingsMutationResult;
44
+ export declare function registerRailHookInSettingsAt(settingsPath: string, memtraceBinary: string, platform?: NodeJS.Platform, options?: RailHookOptions): SettingsMutationResult;
45
+ /** Grep enrichment rides PostToolUse on these tools (design section 9). Claude Code on
46
+ * Windows runs shell commands through its PowerShell tool, so rg/grep/git grep there
47
+ * arrive as PowerShell calls. */
48
+ export declare const ENRICH_HOOK_MATCHER = "Grep|Bash|PowerShell";
49
+ /** Claude hook timeout for the enrichment hooks, in seconds. */
50
+ export declare const ENRICH_HOOK_TIMEOUT_S = 2;
51
+ export interface RailHookOptions {
52
+ /** Register grep-enrichment hooks. Explicit env/config wins; implicit on only in Nudge. */
53
+ enrich?: boolean;
54
+ /** The Rail mode the PreToolUse decision depends on. Default: env, then rail.json. */
55
+ mode?: string;
56
+ }
45
57
  /**
46
58
  * Remove the Memtrace Rail PreToolUse hook from a Claude settings.json.
47
59
  * Preserves unrelated hooks; prunes empty containers. Never touches a
@@ -3,9 +3,10 @@ import path from 'path';
3
3
  import os from 'os';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { copyCanonicalPluginAssets, copyCanonicalPluginManifest, readCanonicalPluginVersion, } from '../../plugin-assets/index.js';
6
+ import { writeManagedSkill, removeManagedSkills } from '../managed-skills.js';
6
7
  import { execCommand, commandExists } from '../utils.js';
7
8
  import { safeReadJson, writeJsonAtomic } from '../fs-safe.js';
8
- import { railIsActive, railHookInvocation, railHookCommand, RAIL_GUARD_MARKER } from '../rail-runtime.js';
9
+ import { railIsActive, railHookInvocation, railHookCommand, RAIL_GUARD_MARKER, enrichEnabled, railMode, railConfig } from '../rail-runtime.js';
9
10
  import { LEGACY_MCP_SERVER_NAME, MEMTRACE_MCP_ENV, removeLegacyMcpServer, } from './shared.js';
10
11
  const PLUGIN_NAME = 'memtrace-skills';
11
12
  const MARKETPLACE_NAME = 'memtrace';
@@ -27,6 +28,17 @@ const MARKETPLACE_SETTING_CONTAINERS = [
27
28
  function isRecord(value) {
28
29
  return typeof value === 'object' && value !== null && !Array.isArray(value);
29
30
  }
31
+ function existingMcpArgs(entry) {
32
+ if (!isRecord(entry) || !Array.isArray(entry.args) || entry.args.length === 0)
33
+ return undefined;
34
+ return entry.args.every((arg) => typeof arg === 'string') ? [...entry.args] : undefined;
35
+ }
36
+ function readExistingMemtraceArgs(filePath) {
37
+ const { value, corrupted } = safeReadJson(filePath);
38
+ if (corrupted || !isRecord(value?.mcpServers))
39
+ return undefined;
40
+ return existingMcpArgs(value.mcpServers.memtrace);
41
+ }
30
42
  /**
31
43
  * Read the existing env block (if any) for the memtrace MCP entry in a Claude
32
44
  * settings/mcp.json file. Returns an empty object if the file or entry is
@@ -82,7 +94,8 @@ async function tryMcpAddJson(memtraceBinary) {
82
94
  const mergedEnv = { ...existingEnv, ...MEMTRACE_MCP_ENV };
83
95
  const config = JSON.stringify({
84
96
  command: memtraceBinary,
85
- args: ['mcp'],
97
+ args: readExistingMemtraceArgs(claudeUserConfigFile())
98
+ ?? readExistingMemtraceArgs(settingsFile) ?? ['mcp'],
86
99
  env: mergedEnv,
87
100
  });
88
101
  // Shell-quote the JSON. Use single quotes; escape any embedded single quote.
@@ -185,7 +198,7 @@ async function tryClaudeCliInstall() {
185
198
  * Merge-add the memtrace MCP server into a Claude settings.json file.
186
199
  * Never overwrites a malformed file — backs it up and returns registered=false.
187
200
  */
188
- export function registerMcpInSettingsAt(settingsPath, memtraceBinary, legacyEnv = {}) {
201
+ export function registerMcpInSettingsAt(settingsPath, memtraceBinary, legacyEnv = {}, legacyArgs) {
189
202
  const { value, corrupted, backupPath } = safeReadJson(settingsPath);
190
203
  if (corrupted) {
191
204
  console.warn(`memtrace: ${settingsPath} is malformed; backed up to ${backupPath}. Skipped MCP registration for Claude.`);
@@ -209,7 +222,9 @@ export function registerMcpInSettingsAt(settingsPath, memtraceBinary, legacyEnv
209
222
  // text can overcome. alwaysLoad opts the memtrace tools back into turn one.
210
223
  settings.mcpServers['memtrace'] = {
211
224
  command: memtraceBinary,
212
- args: ['mcp'],
225
+ // Workspace and transport flags belong to the user. Replacing these on
226
+ // upgrade can send every editor into its own store instead of the owner.
227
+ args: existingMcpArgs(existing) ?? legacyArgs ?? ['mcp'],
213
228
  env: mergedEnv,
214
229
  alwaysLoad: true,
215
230
  };
@@ -248,8 +263,8 @@ export function claudeRailHookCommand(_memtraceBinary, platform = process.platfo
248
263
  * This is what makes Rail "part of memtrace" rather than a manual install:
249
264
  * `memtrace install` wires a PreToolUse hook that pipes every Grep/Glob/Bash
250
265
  * attempt through the Rail router (see {@link claudeRailHookCommand} for the
251
- * transport). While a daemon is running the hook defaults to **observe** —
252
- * `MEMTRACE_RAIL=nudge|rail|strict` opts into more. Mode is intentionally NOT
266
+ * transport). While a daemon is running the hook defaults to **nudge** —
267
+ * context alongside ordinary tools. Explicit modes are preserved. Mode is intentionally NOT
253
268
  * baked into the command so the user can change it via env without
254
269
  * re-installing.
255
270
  *
@@ -257,7 +272,7 @@ export function claudeRailHookCommand(_memtraceBinary, platform = process.platfo
257
272
  * is replaced, and unrelated PreToolUse hooks are preserved. Never overwrites
258
273
  * a malformed file.
259
274
  */
260
- export function registerRailHookInSettingsAt(settingsPath, memtraceBinary, platform = process.platform) {
275
+ export function registerRailHookInSettingsAt(settingsPath, memtraceBinary, platform = process.platform, options = {}) {
261
276
  if (!railIsActive()) {
262
277
  removeRailHookFromSettingsAt(settingsPath);
263
278
  return { registered: false };
@@ -270,26 +285,40 @@ export function registerRailHookInSettingsAt(settingsPath, memtraceBinary, platf
270
285
  const settings = (value ?? {});
271
286
  settings.hooks = isRecord(settings.hooks) ? settings.hooks : {};
272
287
  pruneMemtraceOwnedHookEntries(settings.hooks);
273
- const existing = Array.isArray(settings.hooks.PreToolUse)
274
- ? settings.hooks.PreToolUse
275
- : [];
276
- // Drop any prior memtrace Rail entry so re-install doesn't duplicate it.
277
- const preserved = existing.filter((entry) => !isMemtraceOwnedHookEntry(entry));
278
- preserved.push({
279
- matcher: RAIL_HOOK_MATCHER,
280
- hooks: [
281
- {
282
- type: 'command',
283
- ...(platform === 'win32'
284
- ? railHookInvocation('claude-code')
285
- : { command: claudeRailHookCommand(memtraceBinary, platform) }),
286
- },
287
- ],
288
+ const config = railConfig();
289
+ const mode = options.mode ?? railMode(process.env, config);
290
+ const enrich = options.enrich ?? enrichEnabled(process.env, { ...config, mode });
291
+ const hookFor = (extra) => ({
292
+ type: 'command',
293
+ ...(platform === 'win32'
294
+ ? railHookInvocation('claude-code', extra)
295
+ : { command: railHookCommand('claude-code', platform, extra) }),
288
296
  });
289
- settings.hooks.PreToolUse = preserved;
297
+ const append = (event, entry) => {
298
+ const existing = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
299
+ // Drop any prior memtrace entry so re-install doesn't duplicate it.
300
+ settings.hooks[event] = [...existing.filter((e) => !isMemtraceOwnedHookEntry(e)), entry];
301
+ };
302
+ // With grep enrichment on, the post hook carries the work and the Rail
303
+ // PreToolUse router is only needed when it can deny (rail/strict): each
304
+ // Grep/Bash call then costs exactly one node process (design section 3).
305
+ if (!enrich || mode === 'rail' || mode === 'strict') {
306
+ append('PreToolUse', { matcher: RAIL_HOOK_MATCHER, hooks: [hookFor([])] });
307
+ }
308
+ if (enrich) {
309
+ append('PostToolUse', { matcher: ENRICH_HOOK_MATCHER, hooks: [{ ...hookFor(['--phase', 'post']), timeout: ENRICH_HOOK_TIMEOUT_S }] });
310
+ // Compacted context no longer holds earlier blocks: reset the dedupe.
311
+ append('PreCompact', { hooks: [{ ...hookFor(['--event', 'precompact']), timeout: ENRICH_HOOK_TIMEOUT_S }] });
312
+ }
290
313
  writeJsonAtomic(settingsPath, settings);
291
314
  return { registered: true };
292
315
  }
316
+ /** Grep enrichment rides PostToolUse on these tools (design section 9). Claude Code on
317
+ * Windows runs shell commands through its PowerShell tool, so rg/grep/git grep there
318
+ * arrive as PowerShell calls. */
319
+ export const ENRICH_HOOK_MATCHER = 'Grep|Bash|PowerShell';
320
+ /** Claude hook timeout for the enrichment hooks, in seconds. */
321
+ export const ENRICH_HOOK_TIMEOUT_S = 2;
293
322
  /** True when a PreToolUse entry is a memtrace-owned Rail hook (any generation). */
294
323
  function isRailHookEntry(entry) {
295
324
  return hookCommands(entry).some((command) => command.includes(RAIL_GUARD_MARKER) || RAIL_HOOK_MARKERS.some((marker) => command.includes(marker)));
@@ -510,7 +539,7 @@ export async function registerMcpServer(memtraceBinaryPath) {
510
539
  // Desktop-only installs have no `claude` CLI. Claude Code reads user MCP
511
540
  // registrations from ~/.claude.json, not ~/.claude/settings.json (hooks).
512
541
  // Migrate legacy env values without overriding the authoritative user entry.
513
- registerMcpInSettingsAt(claudeUserConfigFile(), memtraceBinaryPath, readExistingMemtraceEnv(settingsFile));
542
+ registerMcpInSettingsAt(claudeUserConfigFile(), memtraceBinaryPath, readExistingMemtraceEnv(settingsFile), readExistingMemtraceArgs(settingsFile));
514
543
  // Retain staging's mirror for older tooling after migrating the user entry.
515
544
  registerMcpInSettingsAt(settingsFile, memtraceBinaryPath);
516
545
  }
@@ -526,6 +555,9 @@ export function claudeUserConfigFile() {
526
555
  * 3. Register MCP server for memtrace tools
527
556
  */
528
557
  export async function installClaudePlugin(skills, memtraceBinaryPath) {
558
+ // Retire known stock bare copies when moving to plugin delivery. Customized
559
+ // strict policies/opt-outs survive; names alone never prove ownership.
560
+ removeManagedSkills(path.join(os.homedir(), '.claude', 'skills'));
529
561
  // Step 1: make sure Claude sees the public HTTPS marketplace source before
530
562
  // the CLI attempts to clone/update it.
531
563
  enablePluginInSettings();
@@ -555,15 +587,12 @@ export async function installClaudePlugin(skills, memtraceBinaryPath) {
555
587
  // Step 5: Write plugin assets and skills
556
588
  copyBundledPluginAssetsAt(cacheDir);
557
589
  const skillsDir = path.join(cacheDir, 'skills');
558
- if (fs.existsSync(skillsDir)) {
559
- fs.rmSync(skillsDir, { recursive: true });
560
- }
590
+ let skillCount = 0;
561
591
  for (const skill of skills) {
562
592
  const results = transformForClaude(skill);
563
593
  for (const { relativePath, content } of results) {
564
- const fullPath = path.join(cacheDir, relativePath);
565
- fs.mkdirSync(path.dirname(fullPath), { recursive: true });
566
- fs.writeFileSync(fullPath, content);
594
+ if (writeManagedSkill(skillsDir, path.basename(path.dirname(relativePath)), content))
595
+ skillCount++;
567
596
  }
568
597
  }
569
598
  enablePluginInSettings();
@@ -572,27 +601,16 @@ export async function installClaudePlugin(skills, memtraceBinaryPath) {
572
601
  // Skills ship via the memtrace-skills@memtrace plugin only. Writing
573
602
  // bare copies to ~/.claude/skills/memtrace-* duplicates every skill
574
603
  // in Claude Code when the plugin is also enabled (issue #11).
575
- return { cacheDir, skillCount: skills.length };
604
+ return { cacheDir, skillCount };
576
605
  }
577
606
  /**
578
607
  * Write skills to ~/.claude/skills/ for SDK-based integrations.
579
608
  */
580
609
  function writeUserLevelSkills(skills) {
581
610
  const userSkillsDir = path.join(os.homedir(), '.claude', 'skills');
582
- if (fs.existsSync(userSkillsDir)) {
583
- for (const entry of fs.readdirSync(userSkillsDir)) {
584
- if (entry.startsWith('memtrace-')) {
585
- fs.rmSync(path.join(userSkillsDir, entry), { recursive: true, force: true });
586
- }
587
- }
588
- }
589
611
  for (const skill of skills) {
590
- const skillName = skill.filename.replace(/\.md$/, '');
591
- const safeDesc = skill.frontmatter.description.replace(/"/g, '\\"').trim();
592
- const content = `---\nname: ${skillName}\ndescription: "${safeDesc}"\n---\n\n${skill.body}`;
593
- const outDir = path.join(userSkillsDir, skillName);
594
- fs.mkdirSync(outDir, { recursive: true });
595
- fs.writeFileSync(path.join(outDir, 'SKILL.md'), content);
612
+ const content = transformForClaude(skill)[0].content;
613
+ writeManagedSkill(userSkillsDir, skill.filename.replace(/\.md$/, ''), content);
596
614
  }
597
615
  }
598
616
  /**
@@ -625,22 +643,8 @@ export async function uninstallClaudePlugin() {
625
643
  }
626
644
  removeClaudeMarketplaceCacheDirs();
627
645
  removeClaudeInstalledPluginMetadata();
628
- // Clean up user-level skills
629
- const userSkillsDir = path.join(os.homedir(), '.claude', 'skills');
630
- if (fs.existsSync(userSkillsDir)) {
631
- for (const entry of fs.readdirSync(userSkillsDir)) {
632
- if (entry.startsWith('memtrace-')) {
633
- const entryPath = path.join(userSkillsDir, entry);
634
- const stat = fs.statSync(entryPath);
635
- if (stat.isDirectory()) {
636
- fs.rmSync(entryPath, { recursive: true });
637
- }
638
- else if (entry.endsWith('.md')) {
639
- fs.unlinkSync(entryPath);
640
- }
641
- }
642
- }
643
- }
646
+ // Customized skills and their extra assets are not installer-owned.
647
+ removeManagedSkills(path.join(os.homedir(), '.claude', 'skills'));
644
648
  }
645
649
  /**
646
650
  * Write/merge the memtrace MCP server entry into a project-local .mcp.json.
@@ -663,7 +667,7 @@ function writeClaudeLocalMcp(mcpPath, binary) {
663
667
  const mergedEnv = { ...existingEnv, ...MEMTRACE_MCP_ENV };
664
668
  cfg.mcpServers['memtrace'] = {
665
669
  command: binary,
666
- args: ['mcp'],
670
+ args: existingMcpArgs(existing) ?? ['mcp'],
667
671
  env: mergedEnv,
668
672
  };
669
673
  writeJsonAtomic(mcpPath, cfg);
@@ -685,22 +689,11 @@ export const claudeTransformer = {
685
689
  }
686
690
  // Local scope: write skills into <cwd>/.claude/skills/
687
691
  const skillsDir = path.join(ctx.cwd, '.claude', 'skills');
688
- if (fs.existsSync(skillsDir)) {
689
- for (const entry of fs.readdirSync(skillsDir)) {
690
- if (entry.startsWith('memtrace-')) {
691
- fs.rmSync(path.join(skillsDir, entry), { recursive: true, force: true });
692
- }
693
- }
694
- }
695
692
  let count = 0;
696
693
  for (const skill of skills) {
697
- const skillName = skill.filename.replace(/\.md$/, '');
698
- const outDir = path.join(skillsDir, skillName);
699
- fs.mkdirSync(outDir, { recursive: true });
700
- const safeDesc = skill.frontmatter.description.replace(/"/g, '\\"').trim();
701
- const content = `---\nname: ${skillName}\ndescription: "${safeDesc}"\n---\n\n${skill.body}`;
702
- fs.writeFileSync(path.join(outDir, 'SKILL.md'), content);
703
- count++;
694
+ const content = transformForClaude(skill)[0].content;
695
+ if (writeManagedSkill(skillsDir, skill.filename.replace(/\.md$/, ''), content))
696
+ count++;
704
697
  }
705
698
  // MCP for local scope: .mcp.json at project root (Claude's project-level convention)
706
699
  const mcpConfigPath = path.join(ctx.cwd, '.mcp.json');
@@ -724,13 +717,7 @@ export const claudeTransformer = {
724
717
  }
725
718
  // Local: remove <cwd>/.claude/skills/memtrace-* and the memtrace entry from <cwd>/.mcp.json
726
719
  const skillsDir = path.join(ctx.cwd, '.claude', 'skills');
727
- if (fs.existsSync(skillsDir)) {
728
- for (const entry of fs.readdirSync(skillsDir)) {
729
- if (entry.startsWith('memtrace-')) {
730
- fs.rmSync(path.join(skillsDir, entry), { recursive: true, force: true });
731
- }
732
- }
733
- }
720
+ removeManagedSkills(skillsDir);
734
721
  const mcpPath = path.join(ctx.cwd, '.mcp.json');
735
722
  const { value, corrupted } = safeReadJson(mcpPath);
736
723
  if (!corrupted && value?.mcpServers?.['memtrace']) {
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
+ import { writeManagedSkill, removeManagedSkills } from '../managed-skills.js';
4
5
  import { commandExists, execCommand } from '../utils.js';
5
6
  import { registerSettingsHook, removeSettingsHook, CODEX_MATCHER, } from './rail-hooks.js';
6
7
  const MCP_SERVER_NAME = 'memtrace';
@@ -19,11 +20,9 @@ export function codexConfigPath(ctx) {
19
20
  }
20
21
  function writeSkill(skill, rootDir) {
21
22
  const name = skill.filename.replace(/\.md$/, '');
22
- const outDir = path.join(rootDir, name);
23
- fs.mkdirSync(outDir, { recursive: true });
24
23
  const safeDesc = skill.frontmatter.description.replace(/"/g, '\\"').trim();
25
24
  const content = `---\nname: ${name}\ndescription: "${safeDesc}"\n---\n\n${skill.body}`;
26
- fs.writeFileSync(path.join(outDir, 'SKILL.md'), content);
25
+ return writeManagedSkill(rootDir, name, content);
27
26
  }
28
27
  function tomlString(value) {
29
28
  return `"${value
@@ -168,15 +167,7 @@ export const codexTransformer = {
168
167
  name: 'codex',
169
168
  async install(skills, ctx) {
170
169
  const rootDir = skillsRoot(ctx);
171
- if (fs.existsSync(rootDir)) {
172
- for (const entry of fs.readdirSync(rootDir)) {
173
- if (entry.startsWith('memtrace-')) {
174
- fs.rmSync(path.join(rootDir, entry), { recursive: true, force: true });
175
- }
176
- }
177
- }
178
- for (const s of skills)
179
- writeSkill(s, rootDir);
170
+ const written = skills.filter(skill => writeSkill(skill, rootDir)).length;
180
171
  let mcpRegistered = false;
181
172
  const warnings = [];
182
173
  const mcpConfigPath = codexConfigPath(ctx);
@@ -198,7 +189,7 @@ export const codexTransformer = {
198
189
  }
199
190
  return {
200
191
  agent: 'codex',
201
- skillsWritten: skills.length,
192
+ skillsWritten: written,
202
193
  skillsDir: rootDir,
203
194
  mcpConfigPath,
204
195
  mcpRegistered,
@@ -207,13 +198,7 @@ export const codexTransformer = {
207
198
  },
208
199
  async uninstall(ctx) {
209
200
  const rootDir = skillsRoot(ctx);
210
- if (fs.existsSync(rootDir)) {
211
- for (const entry of fs.readdirSync(rootDir)) {
212
- if (entry.startsWith('memtrace-')) {
213
- fs.rmSync(path.join(rootDir, entry), { recursive: true, force: true });
214
- }
215
- }
216
- }
201
+ removeManagedSkills(rootDir);
217
202
  try {
218
203
  removeSettingsHook(codexRailHooksPath(ctx), 'PreToolUse');
219
204
  }
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
+ import { writeManagedSkill, removeManagedSkills } from '../managed-skills.js';
4
5
  import { safeReadJson, writeJsonAtomic } from '../fs-safe.js';
5
6
  import { MEMTRACE_MCP_ENV, removeLegacyMcpServer } from './shared.js';
6
7
  import { registerCursorRailHook, removeCursorRailHook } from './rail-hooks.js';
@@ -18,12 +19,10 @@ function railHooksPath(ctx) {
18
19
  }
19
20
  function writeSkill(skill, rootDir) {
20
21
  const name = skill.filename.replace(/\.md$/, '');
21
- const outDir = path.join(rootDir, name);
22
- fs.mkdirSync(outDir, { recursive: true });
23
22
  const safeDesc = skill.frontmatter.description.replace(/"/g, '\\"').trim();
24
23
  // Cursor requires frontmatter `name` matching the folder name.
25
24
  const content = `---\nname: ${name}\ndescription: "${safeDesc}"\n---\n\n${skill.body}`;
26
- fs.writeFileSync(path.join(outDir, 'SKILL.md'), content);
25
+ return writeManagedSkill(rootDir, name, content);
27
26
  }
28
27
  function isRecord(value) {
29
28
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -96,15 +95,7 @@ export const cursorTransformer = {
96
95
  async install(skills, ctx) {
97
96
  const rootDir = skillsRoot(ctx);
98
97
  const warnings = cursorWorkspaceBoundaryWarnings(ctx);
99
- if (fs.existsSync(rootDir)) {
100
- for (const entry of fs.readdirSync(rootDir)) {
101
- if (entry.startsWith('memtrace-')) {
102
- fs.rmSync(path.join(rootDir, entry), { recursive: true, force: true });
103
- }
104
- }
105
- }
106
- for (const s of skills)
107
- writeSkill(s, rootDir);
98
+ const written = skills.filter(skill => writeSkill(skill, rootDir)).length;
108
99
  let mcpRegistered = false;
109
100
  if (!ctx.skipMcp) {
110
101
  const result = registerCursorMcpAt(mcpPath(ctx), ctx.memtraceBinary);
@@ -123,7 +114,7 @@ export const cursorTransformer = {
123
114
  }
124
115
  return {
125
116
  agent: 'cursor',
126
- skillsWritten: skills.length,
117
+ skillsWritten: written,
127
118
  skillsDir: rootDir,
128
119
  mcpConfigPath: mcpPath(ctx),
129
120
  mcpRegistered,
@@ -133,13 +124,7 @@ export const cursorTransformer = {
133
124
  async uninstall(ctx) {
134
125
  // 1. Remove memtrace-* skill dirs
135
126
  const rootDir = skillsRoot(ctx);
136
- if (fs.existsSync(rootDir)) {
137
- for (const entry of fs.readdirSync(rootDir)) {
138
- if (entry.startsWith('memtrace-')) {
139
- fs.rmSync(path.join(rootDir, entry), { recursive: true, force: true });
140
- }
141
- }
142
- }
127
+ removeManagedSkills(rootDir);
143
128
  // 1b. Remove the Rail hook.
144
129
  try {
145
130
  removeCursorRailHook(railHooksPath(ctx));
@@ -1,6 +1,6 @@
1
- import fs from 'fs';
2
1
  import os from 'os';
3
2
  import path from 'path';
3
+ import { writeManagedSkill, removeManagedSkills } from '../managed-skills.js';
4
4
  import { registerMcpServersJsonAt, removeMcpServersJsonAt, skillName, } from './shared.js';
5
5
  function steeringRoot(ctx) {
6
6
  return ctx.scope === 'global'
@@ -24,25 +24,16 @@ function writeSteering(skill, rootDir) {
24
24
  '',
25
25
  skill.body,
26
26
  ].join('\n');
27
- fs.mkdirSync(rootDir, { recursive: true });
28
- fs.writeFileSync(path.join(rootDir, `${name}.md`), content);
27
+ return writeManagedSkill(rootDir, name, content, true);
29
28
  }
30
29
  function removeMemtraceSteering(rootDir) {
31
- if (!fs.existsSync(rootDir))
32
- return;
33
- for (const entry of fs.readdirSync(rootDir)) {
34
- if (entry.startsWith('memtrace-') && entry.endsWith('.md')) {
35
- fs.rmSync(path.join(rootDir, entry), { force: true });
36
- }
37
- }
30
+ removeManagedSkills(rootDir, true);
38
31
  }
39
32
  export const kiroTransformer = {
40
33
  name: 'kiro',
41
34
  async install(skills, ctx) {
42
35
  const rootDir = steeringRoot(ctx);
43
- removeMemtraceSteering(rootDir);
44
- for (const skill of skills)
45
- writeSteering(skill, rootDir);
36
+ const written = skills.filter(skill => writeSteering(skill, rootDir)).length;
46
37
  let mcpRegistered = false;
47
38
  const warnings = [];
48
39
  const mcpConfigPath = mcpPath(ctx);
@@ -55,7 +46,7 @@ export const kiroTransformer = {
55
46
  }
56
47
  return {
57
48
  agent: 'kiro',
58
- skillsWritten: skills.length,
49
+ skillsWritten: written,
59
50
  skillsDir: rootDir,
60
51
  mcpConfigPath,
61
52
  mcpRegistered,
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import { railIsActive } from '../rail-runtime.js';
3
3
  import path from 'path';
4
4
  import { registerOpenCodeMcpAt, removeMemtraceSkills, removeOpenCodeMcpAt, writeSkills, } from './shared.js';
5
- import { openCodeConfigDir, openCodeRailPluginPaths, openCodePluginSource, } from './rail-hooks.js';
5
+ import { openCodeConfigDir, openCodeRailPluginPaths, openCodePluginSource, writeOpenCodeRailPlugin, } from './rail-hooks.js';
6
6
  function skillsRoot(ctx) {
7
7
  return ctx.scope === 'global'
8
8
  ? path.join(openCodeConfigDir(ctx), 'skills')
@@ -32,13 +32,13 @@ export const opencodeTransformer = {
32
32
  // has no stdio hook). Defaults to observe; fail-open. Best effort.
33
33
  try {
34
34
  const src = openCodePluginSource(ctx.memtraceBinary);
35
- for (const pluginPath of openCodeRailPluginPaths(ctx)) {
36
- if (!railIsActive()) {
35
+ if (railIsActive()) {
36
+ writeOpenCodeRailPlugin(ctx, src);
37
+ }
38
+ else {
39
+ for (const pluginPath of openCodeRailPluginPaths(ctx)) {
37
40
  fs.rmSync(pluginPath, { force: true });
38
- continue;
39
41
  }
40
- fs.mkdirSync(path.dirname(pluginPath), { recursive: true });
41
- fs.writeFileSync(pluginPath, src);
42
42
  }
43
43
  }
44
44
  catch (e) {
@@ -3,6 +3,7 @@ import os from 'os';
3
3
  import path from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { removeMemtraceSkills, skillName } from './shared.js';
6
+ import { writeManagedSkill } from '../managed-skills.js';
6
7
  import { safeReadJson, writeJsonAtomic } from '../fs-safe.js';
7
8
  import { commandExists } from '../utils.js';
8
9
  import { MEMTRACE_TOOL_NAMES } from '../tool-names.js';
@@ -80,15 +81,14 @@ function skillMarkdownForPi(skill, toolNames) {
80
81
  return `---\nname: ${name}\ndescription: "${safeDesc}"${toolsLine}\n---\n\n${body}`;
81
82
  }
82
83
  function writeSkillsForPi(skills, rootDir) {
83
- removeMemtraceSkills(rootDir);
84
+ let written = 0;
84
85
  const toolNames = memtraceToolNames(skills);
85
86
  for (const skill of skills) {
86
87
  const name = skillName(skill);
87
- const outDir = path.join(rootDir, name);
88
- fs.mkdirSync(outDir, { recursive: true });
89
- fs.writeFileSync(path.join(outDir, 'SKILL.md'), skillMarkdownForPi(skill, toolNames));
88
+ if (writeManagedSkill(rootDir, name, skillMarkdownForPi(skill, toolNames)))
89
+ written++;
90
90
  }
91
- return skills.length;
91
+ return written;
92
92
  }
93
93
  function packageEntrySource(entry) {
94
94
  if (typeof entry === 'string')
@@ -18,6 +18,7 @@ export declare function openCodeHomeDir(options?: OpenCodePathOptions): string;
18
18
  export declare function openCodeGlobalConfigDir(options?: OpenCodePathOptions): string;
19
19
  export declare function openCodeConfigDir(ctx: OpenCodePathContext, options?: OpenCodePathOptions): string;
20
20
  export declare function openCodeRailPluginPaths(ctx: OpenCodePathContext, options?: OpenCodePathOptions): string[];
21
+ export declare function writeOpenCodeRailPlugin(ctx: OpenCodePathContext, source: string): string;
21
22
  export declare function isRailHookEntry(entry: unknown): boolean;
22
23
  export interface HookResult {
23
24
  registered: boolean;
@@ -40,7 +41,11 @@ export declare function removeSettingsHook(settingsPath: string, eventName: stri
40
41
  * schema: `{version, hooks: {beforeShellExecution: [{command}]}}`. We intercept
41
42
  * both shell execution (where rg/grep/find run) and file reads.
42
43
  */
43
- export declare function registerCursorRailHook(hooksPath: string, binary: string): HookResult;
44
+ export declare function registerCursorRailHook(hooksPath: string, binary: string, options?: {
45
+ enrich?: boolean;
46
+ }): HookResult;
47
+ export declare const CURSOR_ENRICH_EVENT = "postToolUse";
48
+ export declare const CURSOR_ENRICH_MATCHER = "Shell|Grep";
44
49
  /** Remove the Cursor Rail hook entries. */
45
50
  /** Windsurf Cascade hooks path (user-level). */
46
51
  export declare function windsurfHooksPath(): string;