@haposoft/cafekit 0.9.7 → 0.10.2

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.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * CafeKit install manifest (P1 — ownership tracking)
3
+ *
4
+ * Records every managed file the installer wrote, keyed by SHA-256 of the
5
+ * payload at install time. On a later run we compare three hashes — the file
6
+ * on disk, the hash recorded here, and the new payload — to classify each
7
+ * file as pristine / user-modified / user-created. This is what lets update
8
+ * skip unchanged files and preserve user edits instead of blindly overwriting.
9
+ *
10
+ * The manifest lives at `<platformFolder>/cafekit-manifest.json`, a sibling of
11
+ * the existing `cafekit.json` metadata (which stays pure version metadata).
12
+ *
13
+ * Pure module: no console output, no process control. Side effects limited to
14
+ * reading/writing the manifest file itself.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const crypto = require('crypto');
20
+
21
+ const MANIFEST_FILE = 'cafekit-manifest.json';
22
+ const SCHEMA_VERSION = 1;
23
+
24
+ /**
25
+ * SHA-256 of a buffer or string. Returns hex digest.
26
+ */
27
+ function sha256(data) {
28
+ return crypto.createHash('sha256').update(data).digest('hex');
29
+ }
30
+
31
+ /**
32
+ * SHA-256 of a file on disk, or null if it does not exist / cannot be read.
33
+ */
34
+ function hashFile(filePath) {
35
+ try {
36
+ return sha256(fs.readFileSync(filePath));
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Absolute path to a platform's manifest file.
44
+ */
45
+ function manifestPath(platformFolder) {
46
+ return path.join(platformFolder, MANIFEST_FILE);
47
+ }
48
+
49
+ /**
50
+ * Read a platform manifest. Returns a normalized object even when the file is
51
+ * absent or corrupt, so callers never have to null-check.
52
+ *
53
+ * Shape: { schemaVersion, version, files: { <relPath>: { sha256, version } } }
54
+ */
55
+ function read(platformFolder) {
56
+ const empty = { schemaVersion: SCHEMA_VERSION, version: null, files: {} };
57
+ try {
58
+ const raw = fs.readFileSync(manifestPath(platformFolder), 'utf8');
59
+ const parsed = JSON.parse(raw);
60
+ if (!parsed || typeof parsed !== 'object' || typeof parsed.files !== 'object') {
61
+ return empty;
62
+ }
63
+ return {
64
+ schemaVersion: parsed.schemaVersion || SCHEMA_VERSION,
65
+ version: parsed.version || null,
66
+ files: parsed.files || {}
67
+ };
68
+ } catch {
69
+ return empty;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Create a tracker that accumulates installed-file records during a run and
75
+ * writes them back as the new baseline. relPath keys are normalized to forward
76
+ * slashes and are relative to the platform folder.
77
+ */
78
+ function createTracker(platformFolder, version) {
79
+ const files = {};
80
+
81
+ return {
82
+ /**
83
+ * Record a managed file by its on-disk path. Hashes the file as written.
84
+ * Skips silently if the file is missing (e.g. dry-run).
85
+ */
86
+ record(absPath) {
87
+ const hash = hashFile(absPath);
88
+ if (hash === null) return;
89
+ const rel = path.relative(platformFolder, absPath).replace(/\\/g, '/');
90
+ files[rel] = { sha256: hash, version };
91
+ },
92
+
93
+ /** Number of files recorded so far. */
94
+ count() {
95
+ return Object.keys(files).length;
96
+ },
97
+
98
+ /** Recorded hash for a relPath written earlier this run, or null. */
99
+ recorded(rel) {
100
+ const key = rel.replace(/\\/g, '/');
101
+ return files[key] ? files[key].sha256 : null;
102
+ },
103
+
104
+ /**
105
+ * Write the manifest to disk. Merges onto any prior manifest so files
106
+ * touched by a partial/selective run are not dropped from the baseline.
107
+ */
108
+ write() {
109
+ const prior = read(platformFolder);
110
+ const merged = { ...prior.files, ...files };
111
+ const out = {
112
+ schemaVersion: SCHEMA_VERSION,
113
+ version,
114
+ files: merged
115
+ };
116
+ const target = manifestPath(platformFolder);
117
+ fs.mkdirSync(path.dirname(target), { recursive: true });
118
+ fs.writeFileSync(target, `${JSON.stringify(out, null, 2)}\n`, 'utf8');
119
+ }
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Classify a managed file against the recorded baseline + the incoming payload.
125
+ *
126
+ * @param {string} absPath destination path on disk
127
+ * @param {string} platformFolder root of the platform install (.claude/.opencode)
128
+ * @param {object} manifest result of read(platformFolder)
129
+ * @param {string} payloadHash sha256 of the content about to be written
130
+ * @returns {{state: string, diskHash: string|null, recordedHash: string|null, changed: boolean}}
131
+ * state: 'absent' | 'pristine' | 'user-modified' | 'user-created'
132
+ * changed: whether the payload differs from what's on disk (false → skip copy)
133
+ */
134
+ function classify(absPath, platformFolder, manifest, payloadHash) {
135
+ const rel = path.relative(platformFolder, absPath).replace(/\\/g, '/');
136
+ const recorded = manifest.files[rel];
137
+ const recordedHash = recorded ? recorded.sha256 : null;
138
+ const diskHash = hashFile(absPath);
139
+
140
+ if (diskHash === null) {
141
+ return { state: 'absent', diskHash: null, recordedHash, changed: true };
142
+ }
143
+
144
+ // File exists but we never recorded installing it → user created it.
145
+ if (!recordedHash) {
146
+ return {
147
+ state: 'user-created',
148
+ diskHash,
149
+ recordedHash: null,
150
+ changed: diskHash !== payloadHash
151
+ };
152
+ }
153
+
154
+ // On disk matches our last install → safe to update from payload.
155
+ if (diskHash === recordedHash) {
156
+ return {
157
+ state: 'pristine',
158
+ diskHash,
159
+ recordedHash,
160
+ changed: diskHash !== payloadHash
161
+ };
162
+ }
163
+
164
+ // On disk diverged from our baseline → user edited a managed file.
165
+ return {
166
+ state: 'user-modified',
167
+ diskHash,
168
+ recordedHash,
169
+ changed: diskHash !== payloadHash
170
+ };
171
+ }
172
+
173
+ module.exports = {
174
+ MANIFEST_FILE,
175
+ SCHEMA_VERSION,
176
+ sha256,
177
+ hashFile,
178
+ manifestPath,
179
+ read,
180
+ createTracker,
181
+ classify
182
+ };
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Skill dependency setup primitives (cross-platform, no sudo).
3
+ *
4
+ * Provides the building blocks the skills-setup phase uses to provision the
5
+ * parts that a plain file-copy install can't: a Python venv + pip requirements,
6
+ * skill-local npm installs, and the Puppeteer Chromium binary. System binaries
7
+ * and global npm tools are NOT auto-installed here — they are detected and the
8
+ * phase prints guidance.
9
+ *
10
+ * All commands run via spawnSync (blocking is fine for a CLI). Detection is
11
+ * silent; install callers decide how to surface progress/failures.
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { spawnSync } = require('child_process');
17
+
18
+ const isWindows = process.platform === 'win32';
19
+
20
+ /** Run a command, return { ok, stdout, stderr, status }. Never throws. */
21
+ function run(cmd, args, opts = {}) {
22
+ const r = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
23
+ return {
24
+ ok: !r.error && r.status === 0,
25
+ status: r.status,
26
+ stdout: r.stdout || '',
27
+ stderr: r.stderr || ''
28
+ };
29
+ }
30
+
31
+ /** Is a command available on PATH? */
32
+ function hasCmd(name) {
33
+ const probe = isWindows ? run('where', [name]) : run('which', [name]);
34
+ return probe.ok;
35
+ }
36
+
37
+ /** Find a usable Python launcher ('python3' or 'python'), or null. */
38
+ function findPython() {
39
+ for (const cmd of ['python3', 'python']) {
40
+ if (hasCmd(cmd) && run(cmd, ['--version']).ok) return cmd;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function venvDir(skillsDir) {
46
+ return path.join(skillsDir, '.venv');
47
+ }
48
+
49
+ /** Path to the venv's python interpreter (OS-specific). */
50
+ function venvPython(skillsDir) {
51
+ return isWindows
52
+ ? path.join(venvDir(skillsDir), 'Scripts', 'python.exe')
53
+ : path.join(venvDir(skillsDir), 'bin', 'python3');
54
+ }
55
+
56
+ function venvValid(skillsDir) {
57
+ return fs.existsSync(venvPython(skillsDir));
58
+ }
59
+
60
+ /** Create the venv (idempotent: skips if already valid). Returns true on success. */
61
+ function createVenv(skillsDir, py) {
62
+ if (venvValid(skillsDir)) return true;
63
+ fs.mkdirSync(skillsDir, { recursive: true });
64
+ return run(py, ['-m', 'venv', venvDir(skillsDir)]).ok;
65
+ }
66
+
67
+ /** Upgrade pip inside the venv (best-effort). */
68
+ function pipUpgrade(skillsDir) {
69
+ return run(venvPython(skillsDir), ['-m', 'pip', 'install', '--upgrade', 'pip', '--prefer-binary']).ok;
70
+ }
71
+
72
+ /** Install a requirements.txt into the venv (wheels first). Returns true on success. */
73
+ function pipInstall(skillsDir, requirementsPath) {
74
+ return run(venvPython(skillsDir), [
75
+ '-m', 'pip', 'install', '-r', requirementsPath, '--prefer-binary'
76
+ ]).ok;
77
+ }
78
+
79
+ /** `npm install` in a skill scripts dir. Returns true on success. */
80
+ function npmInstall(dir) {
81
+ return run('npm', ['install', '--no-audit', '--no-fund'], { cwd: dir }).ok;
82
+ }
83
+
84
+ /** Download the Puppeteer Chromium browser for chrome-devtools. */
85
+ function installChromium(scriptsDir) {
86
+ return run('npx', ['--yes', 'puppeteer', 'browsers', 'install', 'chrome'], { cwd: scriptsDir }).ok;
87
+ }
88
+
89
+ /** Does a skill scripts dir declare a given npm dependency? */
90
+ function pkgHasDep(scriptsDir, name) {
91
+ try {
92
+ const pkg = JSON.parse(fs.readFileSync(path.join(scriptsDir, 'package.json'), 'utf8'));
93
+ return Boolean((pkg.dependencies && pkg.dependencies[name]) || (pkg.devDependencies && pkg.devDependencies[name]));
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ /** Download the Playwright Chromium browser (for the pptx html2pptx workflow). */
100
+ function installPlaywrightBrowser(scriptsDir) {
101
+ return run('npx', ['--yes', 'playwright', 'install', 'chromium'], { cwd: scriptsDir }).ok;
102
+ }
103
+
104
+ /**
105
+ * List installed skills that ship a runnable scripts/requirements.txt
106
+ * (excludes the tests/ requirements). Returns [{ skill, file }].
107
+ */
108
+ function collectRequirements(skillsDir) {
109
+ const out = [];
110
+ if (!fs.existsSync(skillsDir)) return out;
111
+ for (const skill of fs.readdirSync(skillsDir)) {
112
+ const file = path.join(skillsDir, skill, 'scripts', 'requirements.txt');
113
+ if (fs.existsSync(file)) out.push({ skill, file });
114
+ }
115
+ return out;
116
+ }
117
+
118
+ /**
119
+ * List installed skills with a scripts/package.json that still needs installing
120
+ * (no node_modules present). Returns [{ skill, dir }].
121
+ */
122
+ function collectSkillPackages(skillsDir) {
123
+ const out = [];
124
+ if (!fs.existsSync(skillsDir)) return out;
125
+ for (const skill of fs.readdirSync(skillsDir)) {
126
+ const dir = path.join(skillsDir, skill, 'scripts');
127
+ if (fs.existsSync(path.join(dir, 'package.json')) && !fs.existsSync(path.join(dir, 'node_modules'))) {
128
+ out.push({ skill, dir });
129
+ }
130
+ }
131
+ return out;
132
+ }
133
+
134
+ /** External binaries some skills shell out to — detected, never auto-installed. */
135
+ const SYSTEM_TOOLS = [
136
+ { cmd: 'ffmpeg', why: 'ai-multimodal (audio/video)', brew: 'brew install ffmpeg', apt: 'sudo apt install ffmpeg', win: 'winget install ffmpeg' },
137
+ { cmd: 'pdftotext', why: 'pdf (text extraction, poppler)', brew: 'brew install poppler', apt: 'sudo apt install poppler-utils', win: 'choco install poppler' },
138
+ { cmd: 'rsvg-convert', why: 'generate-graph (PNG export, librsvg)', brew: 'brew install librsvg', apt: 'sudo apt install librsvg2-bin', win: 'choco install rsvg-convert' },
139
+ { cmd: 'tesseract', why: 'pdf/ai-multimodal (OCR)', brew: 'brew install tesseract', apt: 'sudo apt install tesseract-ocr', win: 'choco install tesseract' }
140
+ ];
141
+
142
+ /** Global npm CLIs some skills expect — detected, never auto-installed. */
143
+ const GLOBAL_NPM = [
144
+ { cmd: 'agent-browser', pkg: 'agent-browser', why: 'agent-browser skill' }
145
+ ];
146
+
147
+ /** Per-OS install hint for a SYSTEM_TOOLS entry. */
148
+ function systemHint(tool) {
149
+ if (process.platform === 'darwin') return tool.brew;
150
+ if (process.platform === 'win32') return tool.win;
151
+ return tool.apt;
152
+ }
153
+
154
+ module.exports = {
155
+ isWindows,
156
+ hasCmd,
157
+ findPython,
158
+ venvDir,
159
+ venvPython,
160
+ venvValid,
161
+ createVenv,
162
+ pipUpgrade,
163
+ pipInstall,
164
+ npmInstall,
165
+ installChromium,
166
+ collectRequirements,
167
+ collectSkillPackages,
168
+ pkgHasDep,
169
+ installPlaywrightBrowser,
170
+ SYSTEM_TOOLS,
171
+ GLOBAL_NPM,
172
+ systemHint
173
+ };
package/bin/lib/ui.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Terminal UI layer for the installer.
3
+ *
4
+ * In an interactive TTY it renders the @clack/prompts experience (framed
5
+ * intro/outro, spinner, ◆/│/└ prompts). In a non-interactive context (piped
6
+ * stdin, CI, --yes) it degrades to plain console output and returns provided
7
+ * fallbacks for any input prompt — so spawned/CI runs never hang and the
8
+ * self-test fixtures keep working unchanged.
9
+ *
10
+ * All @clack/prompts usage is funneled through here and gated by `interactive`.
11
+ */
12
+
13
+ const p = require('@clack/prompts');
14
+ const pc = require('picocolors');
15
+
16
+ /** Cancel sentinel returned by input prompts when the user aborts (Ctrl+C). */
17
+ const CANCEL = Symbol('cafekit.cancel');
18
+
19
+ /** True only when we can drive an interactive terminal. */
20
+ function isInteractive() {
21
+ return Boolean(process.stdin.isTTY)
22
+ && process.env.CI !== 'true'
23
+ && process.env.NON_INTERACTIVE !== 'true';
24
+ }
25
+
26
+ /**
27
+ * Build a UI logger bound to an interactive flag.
28
+ *
29
+ * Interactive methods route to clack; non-interactive ones to console so output
30
+ * stays greppable/plain. `detail()` is for high-frequency per-file progress: it
31
+ * updates the active spinner line (interactive) or prints (non-interactive).
32
+ */
33
+ function createUI(interactive) {
34
+ let spinner = null;
35
+
36
+ return {
37
+ interactive,
38
+ pc,
39
+
40
+ intro(msg) {
41
+ if (interactive) p.intro(msg);
42
+ else console.log(`\n${msg}\n`);
43
+ },
44
+ outro(msg) {
45
+ if (interactive) p.outro(msg);
46
+ else console.log(`\n${msg}`);
47
+ },
48
+ note(msg, title) {
49
+ if (interactive) p.note(msg, title);
50
+ else console.log(`\n${title ? `${title}\n` : ''}${msg}`);
51
+ },
52
+
53
+ startSpinner(msg) {
54
+ if (interactive) {
55
+ spinner = p.spinner();
56
+ spinner.start(msg);
57
+ } else {
58
+ console.log(msg);
59
+ }
60
+ },
61
+ stopSpinner(msg) {
62
+ if (interactive && spinner) {
63
+ spinner.stop(msg);
64
+ spinner = null;
65
+ } else if (msg) {
66
+ console.log(msg);
67
+ }
68
+ },
69
+ /** Ephemeral per-file progress (does not scroll the screen when interactive). */
70
+ detail(msg) {
71
+ if (interactive) {
72
+ if (spinner) spinner.message(msg.replace(/^\s+/, ''));
73
+ } else {
74
+ console.log(msg);
75
+ }
76
+ },
77
+
78
+ success(msg) { interactive ? p.log.success(msg) : console.log(msg); },
79
+ info(msg) { interactive ? p.log.info(msg) : console.log(msg); },
80
+ warn(msg) { interactive ? p.log.warn(msg) : console.log(msg); },
81
+ error(msg) { interactive ? p.log.error(msg) : console.error(msg); },
82
+
83
+ async select(opts, fallback) {
84
+ if (!interactive) return fallback;
85
+ const r = await p.select(opts);
86
+ return p.isCancel(r) ? CANCEL : r;
87
+ },
88
+ async multiselect(opts, fallback) {
89
+ if (!interactive) return fallback;
90
+ const r = await p.multiselect(opts);
91
+ return p.isCancel(r) ? CANCEL : r;
92
+ },
93
+ async text(opts, fallback = '') {
94
+ if (!interactive) return fallback;
95
+ const r = await p.text(opts);
96
+ return p.isCancel(r) ? CANCEL : r;
97
+ },
98
+ async confirm(opts, fallback = false) {
99
+ if (!interactive) return fallback;
100
+ const r = await p.confirm(opts);
101
+ return p.isCancel(r) ? CANCEL : r;
102
+ },
103
+
104
+ isCancel(v) { return v === CANCEL; }
105
+ };
106
+ }
107
+
108
+ module.exports = { isInteractive, createUI, CANCEL };
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Phase: Claude/runtime file installation.
3
+ *
4
+ * ROUTING.md, runtime files (hooks/status/gitignore/runtime.json), obsolete
5
+ * runtime cleanup, CLAUDE.md, and the rules/ tree. All copies are
6
+ * ownership-aware — notably CLAUDE.md and rules/ no longer force-overwrite, so
7
+ * user edits survive re-installs (the latent wipe is fixed).
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const {
13
+ PLATFORMS,
14
+ isClaudeCompatibleRuntime,
15
+ getRuntimeSupportTargetDir,
16
+ getCopyOptions
17
+ } = require('../lib/context');
18
+ const { writeManagedFile, copyManagedTree } = require('../lib/managed-writer');
19
+ const { report, treeAction } = require('./report');
20
+ const { normalizeOpenCodeBody } = require('../lib/opencode-install');
21
+
22
+ const SRC = path.join(__dirname, '../../src');
23
+
24
+ /** Copy ROUTING.md (SKILLS_DIR substituted; OpenCode body normalized). */
25
+ function copyRoutingFile(ctx, platformKey) {
26
+ const platform = PLATFORMS[platformKey];
27
+ const src = path.join(SRC, platform.sourceDir, 'ROUTING.md');
28
+ const dest = path.join(platform.folder, 'ROUTING.md');
29
+ if (!fs.existsSync(src)) return;
30
+
31
+ const transform = (content) => {
32
+ let c = content.replace(/\{\{SKILLS_DIR\}\}/g, platform.skillsRef);
33
+ if (platformKey === 'opencode') c = normalizeOpenCodeBody(c);
34
+ return c;
35
+ };
36
+ const { action } = writeManagedFile({
37
+ src, dest, platformFolder: platform.folder, ctx, tracker: ctx.trackers[platformKey], transform
38
+ });
39
+ report(ctx, action, 'ROUTING.md');
40
+ }
41
+
42
+ /** Copy Claude runtime files declared in the migration manifest. Claude only. */
43
+ function copyClaudeRuntimeFiles(ctx, platformKey) {
44
+ if (platformKey !== 'claude') return;
45
+ const manifest = ctx.manifest;
46
+ if (!manifest?.runtime?.files) return;
47
+
48
+ const srcBase = path.join(SRC, 'claude');
49
+ const targetBase = PLATFORMS.claude.folder;
50
+ const tracker = ctx.trackers.claude;
51
+
52
+ manifest.runtime.files.forEach((relPath) => {
53
+ const srcPath = path.join(srcBase, relPath);
54
+ const targetRelPath = relPath === 'gitignore' ? '.gitignore' : relPath;
55
+ const targetPath = path.join(targetBase, targetRelPath);
56
+
57
+ if (!fs.existsSync(srcPath)) {
58
+ ctx.ui.warn(`Runtime file not found: ${relPath}`);
59
+ ctx.results.missingDependencies++;
60
+ return;
61
+ }
62
+
63
+ const { action } = writeManagedFile({
64
+ src: srcPath, dest: targetPath, platformFolder: targetBase, ctx, tracker
65
+ });
66
+ report(ctx, action, `Runtime: ${targetRelPath}`);
67
+ });
68
+ }
69
+
70
+ /** Delete runtime files marked obsolete by the manifest. Claude only. */
71
+ function removeObsoleteClaudeRuntimeFiles(ctx, platformKey) {
72
+ if (platformKey !== 'claude') return;
73
+
74
+ const obsoleteFiles = ctx.manifest?.obsolete?.runtimeFiles || [];
75
+ const targetBase = PLATFORMS.claude.folder;
76
+
77
+ obsoleteFiles.forEach((relPath) => {
78
+ const targetPath = path.join(targetBase, relPath);
79
+ if (!fs.existsSync(targetPath)) return;
80
+
81
+ if (!ctx.dryRun) fs.rmSync(targetPath, { force: true });
82
+ ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Removed obsolete runtime: ${relPath}`);
83
+ ctx.results.updated++;
84
+ });
85
+ }
86
+
87
+ /** Install CLAUDE.md at the project root (ownership-aware). Claude only. */
88
+ function copyClaudeMdFile(ctx, platformKey) {
89
+ if (platformKey !== 'claude') return;
90
+
91
+ const src = path.join(SRC, 'claude/CLAUDE.md');
92
+ const dest = 'CLAUDE.md'; // project root
93
+ if (!fs.existsSync(src)) {
94
+ ctx.ui.warn('CLAUDE.md template not found');
95
+ ctx.results.missingDependencies++;
96
+ return;
97
+ }
98
+
99
+ const { action } = writeManagedFile({
100
+ src, dest, platformFolder: PLATFORMS.claude.folder, ctx, tracker: ctx.trackers.claude
101
+ });
102
+ report(ctx, action, 'CLAUDE.md');
103
+ }
104
+
105
+ /** Copy the rules/ tree (ownership-aware; no longer force-overwrites). */
106
+ function copyRulesDirectory(ctx, platformKey) {
107
+ if (!isClaudeCompatibleRuntime(platformKey)) return;
108
+
109
+ const src = path.join(SRC, 'claude/rules');
110
+ const dest = getRuntimeSupportTargetDir(platformKey, 'rules');
111
+ if (!fs.existsSync(src)) {
112
+ ctx.ui.warn('rules/ directory not found');
113
+ ctx.results.missingDependencies++;
114
+ return;
115
+ }
116
+
117
+ const transform = platformKey === 'opencode'
118
+ ? getCopyOptions('opencode', {}).transform
119
+ : undefined;
120
+ const agg = copyManagedTree({
121
+ src, dest, platformFolder: PLATFORMS[platformKey].folder, ctx, tracker: ctx.trackers[platformKey], transform
122
+ });
123
+ report(ctx, treeAction(agg), 'rules/ directory');
124
+ }
125
+
126
+ module.exports = {
127
+ copyRoutingFile,
128
+ copyClaudeRuntimeFiles,
129
+ removeObsoleteClaudeRuntimeFiles,
130
+ copyClaudeMdFile,
131
+ copyRulesDirectory
132
+ };