cc4pm 1.8.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/.claude-plugin/README.md +17 -0
  2. package/.claude-plugin/plugin.json +25 -0
  3. package/LICENSE +21 -0
  4. package/README.md +157 -0
  5. package/README.zh-CN.md +134 -0
  6. package/contexts/dev.md +20 -0
  7. package/contexts/research.md +26 -0
  8. package/contexts/review.md +22 -0
  9. package/examples/CLAUDE.md +100 -0
  10. package/examples/statusline.json +19 -0
  11. package/examples/user-CLAUDE.md +109 -0
  12. package/install.sh +17 -0
  13. package/manifests/install-components.json +173 -0
  14. package/manifests/install-modules.json +335 -0
  15. package/manifests/install-profiles.json +75 -0
  16. package/package.json +117 -0
  17. package/schemas/ecc-install-config.schema.json +58 -0
  18. package/schemas/hooks.schema.json +197 -0
  19. package/schemas/install-components.schema.json +56 -0
  20. package/schemas/install-modules.schema.json +105 -0
  21. package/schemas/install-profiles.schema.json +45 -0
  22. package/schemas/install-state.schema.json +210 -0
  23. package/schemas/package-manager.schema.json +23 -0
  24. package/schemas/plugin.schema.json +58 -0
  25. package/scripts/ci/catalog.js +83 -0
  26. package/scripts/ci/validate-agents.js +81 -0
  27. package/scripts/ci/validate-commands.js +135 -0
  28. package/scripts/ci/validate-hooks.js +239 -0
  29. package/scripts/ci/validate-install-manifests.js +211 -0
  30. package/scripts/ci/validate-no-personal-paths.js +63 -0
  31. package/scripts/ci/validate-rules.js +81 -0
  32. package/scripts/ci/validate-skills.js +54 -0
  33. package/scripts/claw.js +468 -0
  34. package/scripts/doctor.js +110 -0
  35. package/scripts/ecc.js +194 -0
  36. package/scripts/hooks/auto-tmux-dev.js +88 -0
  37. package/scripts/hooks/check-console-log.js +71 -0
  38. package/scripts/hooks/check-hook-enabled.js +12 -0
  39. package/scripts/hooks/cost-tracker.js +78 -0
  40. package/scripts/hooks/doc-file-warning.js +63 -0
  41. package/scripts/hooks/evaluate-session.js +100 -0
  42. package/scripts/hooks/insaits-security-monitor.py +269 -0
  43. package/scripts/hooks/insaits-security-wrapper.js +88 -0
  44. package/scripts/hooks/post-bash-build-complete.js +27 -0
  45. package/scripts/hooks/post-bash-pr-created.js +36 -0
  46. package/scripts/hooks/post-edit-console-warn.js +54 -0
  47. package/scripts/hooks/post-edit-format.js +109 -0
  48. package/scripts/hooks/post-edit-typecheck.js +96 -0
  49. package/scripts/hooks/pre-bash-dev-server-block.js +187 -0
  50. package/scripts/hooks/pre-bash-git-push-reminder.js +28 -0
  51. package/scripts/hooks/pre-bash-tmux-reminder.js +33 -0
  52. package/scripts/hooks/pre-compact.js +48 -0
  53. package/scripts/hooks/pre-write-doc-warn.js +9 -0
  54. package/scripts/hooks/quality-gate.js +168 -0
  55. package/scripts/hooks/run-with-flags-shell.sh +32 -0
  56. package/scripts/hooks/run-with-flags.js +120 -0
  57. package/scripts/hooks/session-end-marker.js +15 -0
  58. package/scripts/hooks/session-end.js +299 -0
  59. package/scripts/hooks/session-start.js +97 -0
  60. package/scripts/hooks/suggest-compact.js +80 -0
  61. package/scripts/install-apply.js +137 -0
  62. package/scripts/install-plan.js +254 -0
  63. package/scripts/lib/hook-flags.js +74 -0
  64. package/scripts/lib/install/apply.js +23 -0
  65. package/scripts/lib/install/config.js +82 -0
  66. package/scripts/lib/install/request.js +113 -0
  67. package/scripts/lib/install/runtime.js +42 -0
  68. package/scripts/lib/install-executor.js +605 -0
  69. package/scripts/lib/install-lifecycle.js +763 -0
  70. package/scripts/lib/install-manifests.js +305 -0
  71. package/scripts/lib/install-state.js +120 -0
  72. package/scripts/lib/install-targets/antigravity-project.js +9 -0
  73. package/scripts/lib/install-targets/claude-home.js +10 -0
  74. package/scripts/lib/install-targets/codex-home.js +10 -0
  75. package/scripts/lib/install-targets/cursor-project.js +10 -0
  76. package/scripts/lib/install-targets/helpers.js +89 -0
  77. package/scripts/lib/install-targets/opencode-home.js +10 -0
  78. package/scripts/lib/install-targets/registry.js +64 -0
  79. package/scripts/lib/orchestration-session.js +299 -0
  80. package/scripts/lib/package-manager.d.ts +119 -0
  81. package/scripts/lib/package-manager.js +431 -0
  82. package/scripts/lib/project-detect.js +428 -0
  83. package/scripts/lib/resolve-formatter.js +185 -0
  84. package/scripts/lib/session-adapters/canonical-session.js +138 -0
  85. package/scripts/lib/session-adapters/claude-history.js +149 -0
  86. package/scripts/lib/session-adapters/dmux-tmux.js +80 -0
  87. package/scripts/lib/session-adapters/registry.js +111 -0
  88. package/scripts/lib/session-aliases.d.ts +136 -0
  89. package/scripts/lib/session-aliases.js +481 -0
  90. package/scripts/lib/session-manager.d.ts +131 -0
  91. package/scripts/lib/session-manager.js +464 -0
  92. package/scripts/lib/shell-split.js +86 -0
  93. package/scripts/lib/skill-improvement/amendify.js +89 -0
  94. package/scripts/lib/skill-improvement/evaluate.js +59 -0
  95. package/scripts/lib/skill-improvement/health.js +118 -0
  96. package/scripts/lib/skill-improvement/observations.js +108 -0
  97. package/scripts/lib/tmux-worktree-orchestrator.js +491 -0
  98. package/scripts/lib/utils.d.ts +183 -0
  99. package/scripts/lib/utils.js +543 -0
  100. package/scripts/list-installed.js +90 -0
  101. package/scripts/orchestrate-codex-worker.sh +92 -0
  102. package/scripts/orchestrate-worktrees.js +108 -0
  103. package/scripts/orchestration-status.js +62 -0
  104. package/scripts/repair.js +97 -0
  105. package/scripts/session-inspect.js +150 -0
  106. package/scripts/setup-package-manager.js +204 -0
  107. package/scripts/skill-create-output.js +244 -0
  108. package/scripts/uninstall.js +96 -0
@@ -0,0 +1,428 @@
1
+ /**
2
+ * Project type and framework detection
3
+ *
4
+ * Cross-platform (Windows, macOS, Linux) project type detection
5
+ * by inspecting files in the working directory.
6
+ *
7
+ * Resolves: https://github.com/istarwyh/cc4pm/issues/293
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ /**
14
+ * Language detection rules.
15
+ * Each rule checks for marker files or glob patterns in the project root.
16
+ */
17
+ const LANGUAGE_RULES = [
18
+ {
19
+ type: 'python',
20
+ markers: ['requirements.txt', 'pyproject.toml', 'setup.py', 'setup.cfg', 'Pipfile', 'poetry.lock'],
21
+ extensions: ['.py']
22
+ },
23
+ {
24
+ type: 'typescript',
25
+ markers: ['tsconfig.json', 'tsconfig.build.json'],
26
+ extensions: ['.ts', '.tsx']
27
+ },
28
+ {
29
+ type: 'javascript',
30
+ markers: ['package.json', 'jsconfig.json'],
31
+ extensions: ['.js', '.jsx', '.mjs']
32
+ },
33
+ {
34
+ type: 'golang',
35
+ markers: ['go.mod', 'go.sum'],
36
+ extensions: ['.go']
37
+ },
38
+ {
39
+ type: 'rust',
40
+ markers: ['Cargo.toml', 'Cargo.lock'],
41
+ extensions: ['.rs']
42
+ },
43
+ {
44
+ type: 'ruby',
45
+ markers: ['Gemfile', 'Gemfile.lock', 'Rakefile'],
46
+ extensions: ['.rb']
47
+ },
48
+ {
49
+ type: 'java',
50
+ markers: ['pom.xml', 'build.gradle', 'build.gradle.kts'],
51
+ extensions: ['.java']
52
+ },
53
+ {
54
+ type: 'csharp',
55
+ markers: [],
56
+ extensions: ['.cs', '.csproj', '.sln']
57
+ },
58
+ {
59
+ type: 'swift',
60
+ markers: ['Package.swift'],
61
+ extensions: ['.swift']
62
+ },
63
+ {
64
+ type: 'kotlin',
65
+ markers: [],
66
+ extensions: ['.kt', '.kts']
67
+ },
68
+ {
69
+ type: 'elixir',
70
+ markers: ['mix.exs'],
71
+ extensions: ['.ex', '.exs']
72
+ },
73
+ {
74
+ type: 'php',
75
+ markers: ['composer.json', 'composer.lock'],
76
+ extensions: ['.php']
77
+ }
78
+ ];
79
+
80
+ /**
81
+ * Framework detection rules.
82
+ * Checked after language detection for more specific identification.
83
+ */
84
+ const FRAMEWORK_RULES = [
85
+ // Python frameworks
86
+ { framework: 'django', language: 'python', markers: ['manage.py'], packageKeys: ['django'] },
87
+ { framework: 'fastapi', language: 'python', markers: [], packageKeys: ['fastapi'] },
88
+ { framework: 'flask', language: 'python', markers: [], packageKeys: ['flask'] },
89
+
90
+ // JavaScript/TypeScript frameworks
91
+ { framework: 'nextjs', language: 'typescript', markers: ['next.config.js', 'next.config.mjs', 'next.config.ts'], packageKeys: ['next'] },
92
+ { framework: 'react', language: 'typescript', markers: [], packageKeys: ['react'] },
93
+ { framework: 'vue', language: 'typescript', markers: ['vue.config.js'], packageKeys: ['vue'] },
94
+ { framework: 'angular', language: 'typescript', markers: ['angular.json'], packageKeys: ['@angular/core'] },
95
+ { framework: 'svelte', language: 'typescript', markers: ['svelte.config.js'], packageKeys: ['svelte'] },
96
+ { framework: 'express', language: 'javascript', markers: [], packageKeys: ['express'] },
97
+ { framework: 'nestjs', language: 'typescript', markers: ['nest-cli.json'], packageKeys: ['@nestjs/core'] },
98
+ { framework: 'remix', language: 'typescript', markers: [], packageKeys: ['@remix-run/node', '@remix-run/react'] },
99
+ { framework: 'astro', language: 'typescript', markers: ['astro.config.mjs', 'astro.config.ts'], packageKeys: ['astro'] },
100
+ { framework: 'nuxt', language: 'typescript', markers: ['nuxt.config.js', 'nuxt.config.ts'], packageKeys: ['nuxt'] },
101
+ { framework: 'electron', language: 'typescript', markers: [], packageKeys: ['electron'] },
102
+
103
+ // Ruby frameworks
104
+ { framework: 'rails', language: 'ruby', markers: ['config/routes.rb', 'bin/rails'], packageKeys: [] },
105
+
106
+ // Go frameworks
107
+ { framework: 'gin', language: 'golang', markers: [], packageKeys: ['github.com/gin-gonic/gin'] },
108
+ { framework: 'echo', language: 'golang', markers: [], packageKeys: ['github.com/labstack/echo'] },
109
+
110
+ // Rust frameworks
111
+ { framework: 'actix', language: 'rust', markers: [], packageKeys: ['actix-web'] },
112
+ { framework: 'axum', language: 'rust', markers: [], packageKeys: ['axum'] },
113
+
114
+ // Java frameworks
115
+ { framework: 'spring', language: 'java', markers: [], packageKeys: ['spring-boot', 'org.springframework'] },
116
+
117
+ // PHP frameworks
118
+ { framework: 'laravel', language: 'php', markers: ['artisan'], packageKeys: ['laravel/framework'] },
119
+ { framework: 'symfony', language: 'php', markers: ['symfony.lock'], packageKeys: ['symfony/framework-bundle'] },
120
+
121
+ // Elixir frameworks
122
+ { framework: 'phoenix', language: 'elixir', markers: [], packageKeys: ['phoenix'] }
123
+ ];
124
+
125
+ /**
126
+ * Check if a file exists relative to the project directory
127
+ * @param {string} projectDir - Project root directory
128
+ * @param {string} filePath - Relative file path
129
+ * @returns {boolean}
130
+ */
131
+ function fileExists(projectDir, filePath) {
132
+ try {
133
+ return fs.existsSync(path.join(projectDir, filePath));
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Check if any file with given extension exists in the project root (non-recursive, top-level only)
141
+ * @param {string} projectDir - Project root directory
142
+ * @param {string[]} extensions - File extensions to check
143
+ * @returns {boolean}
144
+ */
145
+ function hasFileWithExtension(projectDir, extensions) {
146
+ try {
147
+ const entries = fs.readdirSync(projectDir, { withFileTypes: true });
148
+ return entries.some(entry => {
149
+ if (!entry.isFile()) return false;
150
+ const ext = path.extname(entry.name);
151
+ return extensions.includes(ext);
152
+ });
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Read and parse package.json dependencies
160
+ * @param {string} projectDir - Project root directory
161
+ * @returns {string[]} Array of dependency names
162
+ */
163
+ function getPackageJsonDeps(projectDir) {
164
+ try {
165
+ const pkgPath = path.join(projectDir, 'package.json');
166
+ if (!fs.existsSync(pkgPath)) return [];
167
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
168
+ return [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {})];
169
+ } catch {
170
+ return [];
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Read requirements.txt or pyproject.toml for Python package names
176
+ * @param {string} projectDir - Project root directory
177
+ * @returns {string[]} Array of dependency names (lowercase)
178
+ */
179
+ function getPythonDeps(projectDir) {
180
+ const deps = [];
181
+
182
+ // requirements.txt
183
+ try {
184
+ const reqPath = path.join(projectDir, 'requirements.txt');
185
+ if (fs.existsSync(reqPath)) {
186
+ const content = fs.readFileSync(reqPath, 'utf8');
187
+ content.split('\n').forEach(line => {
188
+ const trimmed = line.trim();
189
+ if (trimmed && !trimmed.startsWith('#') && !trimmed.startsWith('-')) {
190
+ const name = trimmed
191
+ .split(/[>=<![;]/)[0]
192
+ .trim()
193
+ .toLowerCase();
194
+ if (name) deps.push(name);
195
+ }
196
+ });
197
+ }
198
+ } catch {
199
+ /* ignore */
200
+ }
201
+
202
+ // pyproject.toml — simple extraction of dependency names
203
+ try {
204
+ const tomlPath = path.join(projectDir, 'pyproject.toml');
205
+ if (fs.existsSync(tomlPath)) {
206
+ const content = fs.readFileSync(tomlPath, 'utf8');
207
+ const depMatches = content.match(/dependencies\s*=\s*\[([\s\S]*?)\]/);
208
+ if (depMatches) {
209
+ const block = depMatches[1];
210
+ block.match(/"([^"]+)"/g)?.forEach(m => {
211
+ const name = m
212
+ .replace(/"/g, '')
213
+ .split(/[>=<![;]/)[0]
214
+ .trim()
215
+ .toLowerCase();
216
+ if (name) deps.push(name);
217
+ });
218
+ }
219
+ }
220
+ } catch {
221
+ /* ignore */
222
+ }
223
+
224
+ return deps;
225
+ }
226
+
227
+ /**
228
+ * Read go.mod for Go module dependencies
229
+ * @param {string} projectDir - Project root directory
230
+ * @returns {string[]} Array of module paths
231
+ */
232
+ function getGoDeps(projectDir) {
233
+ try {
234
+ const modPath = path.join(projectDir, 'go.mod');
235
+ if (!fs.existsSync(modPath)) return [];
236
+ const content = fs.readFileSync(modPath, 'utf8');
237
+ const deps = [];
238
+ const requireBlock = content.match(/require\s*\(([\s\S]*?)\)/);
239
+ if (requireBlock) {
240
+ requireBlock[1].split('\n').forEach(line => {
241
+ const trimmed = line.trim();
242
+ if (trimmed && !trimmed.startsWith('//')) {
243
+ const parts = trimmed.split(/\s+/);
244
+ if (parts[0]) deps.push(parts[0]);
245
+ }
246
+ });
247
+ }
248
+ return deps;
249
+ } catch {
250
+ return [];
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Read Cargo.toml for Rust crate dependencies
256
+ * @param {string} projectDir - Project root directory
257
+ * @returns {string[]} Array of crate names
258
+ */
259
+ function getRustDeps(projectDir) {
260
+ try {
261
+ const cargoPath = path.join(projectDir, 'Cargo.toml');
262
+ if (!fs.existsSync(cargoPath)) return [];
263
+ const content = fs.readFileSync(cargoPath, 'utf8');
264
+ const deps = [];
265
+ // Match [dependencies] and [dev-dependencies] sections
266
+ const sections = content.match(/\[(dev-)?dependencies\]([\s\S]*?)(?=\n\[|$)/g);
267
+ if (sections) {
268
+ sections.forEach(section => {
269
+ section.split('\n').forEach(line => {
270
+ const match = line.match(/^([a-zA-Z0-9_-]+)\s*=/);
271
+ if (match && !line.startsWith('[')) {
272
+ deps.push(match[1]);
273
+ }
274
+ });
275
+ });
276
+ }
277
+ return deps;
278
+ } catch {
279
+ return [];
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Read composer.json for PHP package dependencies
285
+ * @param {string} projectDir - Project root directory
286
+ * @returns {string[]} Array of package names
287
+ */
288
+ function getComposerDeps(projectDir) {
289
+ try {
290
+ const composerPath = path.join(projectDir, 'composer.json');
291
+ if (!fs.existsSync(composerPath)) return [];
292
+ const composer = JSON.parse(fs.readFileSync(composerPath, 'utf8'));
293
+ return [...Object.keys(composer.require || {}), ...Object.keys(composer['require-dev'] || {})];
294
+ } catch {
295
+ return [];
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Read mix.exs for Elixir dependencies (simple pattern match)
301
+ * @param {string} projectDir - Project root directory
302
+ * @returns {string[]} Array of dependency atom names
303
+ */
304
+ function getElixirDeps(projectDir) {
305
+ try {
306
+ const mixPath = path.join(projectDir, 'mix.exs');
307
+ if (!fs.existsSync(mixPath)) return [];
308
+ const content = fs.readFileSync(mixPath, 'utf8');
309
+ const deps = [];
310
+ const matches = content.match(/\{:(\w+)/g);
311
+ if (matches) {
312
+ matches.forEach(m => deps.push(m.replace('{:', '')));
313
+ }
314
+ return deps;
315
+ } catch {
316
+ return [];
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Detect project languages and frameworks
322
+ * @param {string} [projectDir] - Project directory (defaults to cwd)
323
+ * @returns {{ languages: string[], frameworks: string[], primary: string, projectDir: string }}
324
+ */
325
+ function detectProjectType(projectDir) {
326
+ projectDir = projectDir || process.cwd();
327
+ const languages = [];
328
+ const frameworks = [];
329
+
330
+ // Step 1: Detect languages
331
+ for (const rule of LANGUAGE_RULES) {
332
+ const hasMarker = rule.markers.some(m => fileExists(projectDir, m));
333
+ const hasExt = rule.extensions.length > 0 && hasFileWithExtension(projectDir, rule.extensions);
334
+
335
+ if (hasMarker || hasExt) {
336
+ languages.push(rule.type);
337
+ }
338
+ }
339
+
340
+ // Deduplicate: if both typescript and javascript detected, keep typescript
341
+ if (languages.includes('typescript') && languages.includes('javascript')) {
342
+ const idx = languages.indexOf('javascript');
343
+ if (idx !== -1) languages.splice(idx, 1);
344
+ }
345
+
346
+ // Step 2: Detect frameworks based on markers and dependencies
347
+ const npmDeps = getPackageJsonDeps(projectDir);
348
+ const pyDeps = getPythonDeps(projectDir);
349
+ const goDeps = getGoDeps(projectDir);
350
+ const rustDeps = getRustDeps(projectDir);
351
+ const composerDeps = getComposerDeps(projectDir);
352
+ const elixirDeps = getElixirDeps(projectDir);
353
+
354
+ for (const rule of FRAMEWORK_RULES) {
355
+ // Check marker files
356
+ const hasMarker = rule.markers.some(m => fileExists(projectDir, m));
357
+
358
+ // Check package dependencies
359
+ let hasDep = false;
360
+ if (rule.packageKeys.length > 0) {
361
+ let depList = [];
362
+ switch (rule.language) {
363
+ case 'python':
364
+ depList = pyDeps;
365
+ break;
366
+ case 'typescript':
367
+ case 'javascript':
368
+ depList = npmDeps;
369
+ break;
370
+ case 'golang':
371
+ depList = goDeps;
372
+ break;
373
+ case 'rust':
374
+ depList = rustDeps;
375
+ break;
376
+ case 'php':
377
+ depList = composerDeps;
378
+ break;
379
+ case 'elixir':
380
+ depList = elixirDeps;
381
+ break;
382
+ }
383
+ hasDep = rule.packageKeys.some(key => depList.some(dep => dep.toLowerCase().includes(key.toLowerCase())));
384
+ }
385
+
386
+ if (hasMarker || hasDep) {
387
+ frameworks.push(rule.framework);
388
+ }
389
+ }
390
+
391
+ // Step 3: Determine primary type
392
+ let primary = 'unknown';
393
+ if (frameworks.length > 0) {
394
+ primary = frameworks[0];
395
+ } else if (languages.length > 0) {
396
+ primary = languages[0];
397
+ }
398
+
399
+ // Determine if fullstack (both frontend and backend languages)
400
+ const frontendSignals = ['react', 'vue', 'angular', 'svelte', 'nextjs', 'nuxt', 'astro', 'remix'];
401
+ const backendSignals = ['django', 'fastapi', 'flask', 'express', 'nestjs', 'rails', 'spring', 'laravel', 'phoenix', 'gin', 'echo', 'actix', 'axum'];
402
+ const hasFrontend = frameworks.some(f => frontendSignals.includes(f));
403
+ const hasBackend = frameworks.some(f => backendSignals.includes(f));
404
+
405
+ if (hasFrontend && hasBackend) {
406
+ primary = 'fullstack';
407
+ }
408
+
409
+ return {
410
+ languages,
411
+ frameworks,
412
+ primary,
413
+ projectDir
414
+ };
415
+ }
416
+
417
+ module.exports = {
418
+ detectProjectType,
419
+ LANGUAGE_RULES,
420
+ FRAMEWORK_RULES,
421
+ // Exported for testing
422
+ getPackageJsonDeps,
423
+ getPythonDeps,
424
+ getGoDeps,
425
+ getRustDeps,
426
+ getComposerDeps,
427
+ getElixirDeps
428
+ };
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Shared formatter resolution utilities with caching.
3
+ *
4
+ * Extracts project-root discovery, formatter detection, and binary
5
+ * resolution into a single module so that post-edit-format.js and
6
+ * quality-gate.js avoid duplicating work and filesystem lookups.
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ // ── Caches (per-process, cleared on next hook invocation) ───────────
15
+ const projectRootCache = new Map();
16
+ const formatterCache = new Map();
17
+ const binCache = new Map();
18
+
19
+ // ── Config file lists (single source of truth) ─────────────────────
20
+
21
+ const BIOME_CONFIGS = ['biome.json', 'biome.jsonc'];
22
+
23
+ const PRETTIER_CONFIGS = [
24
+ '.prettierrc',
25
+ '.prettierrc.json',
26
+ '.prettierrc.js',
27
+ '.prettierrc.cjs',
28
+ '.prettierrc.mjs',
29
+ '.prettierrc.yml',
30
+ '.prettierrc.yaml',
31
+ '.prettierrc.toml',
32
+ 'prettier.config.js',
33
+ 'prettier.config.cjs',
34
+ 'prettier.config.mjs'
35
+ ];
36
+
37
+ const PROJECT_ROOT_MARKERS = ['package.json', ...BIOME_CONFIGS, ...PRETTIER_CONFIGS];
38
+
39
+ // ── Windows .cmd shim mapping ───────────────────────────────────────
40
+ const WIN_CMD_SHIMS = { npx: 'npx.cmd', pnpm: 'pnpm.cmd', yarn: 'yarn.cmd', bunx: 'bunx.cmd' };
41
+
42
+ // ── Formatter → package name mapping ────────────────────────────────
43
+ const FORMATTER_PACKAGES = {
44
+ biome: { binName: 'biome', pkgName: '@biomejs/biome' },
45
+ prettier: { binName: 'prettier', pkgName: 'prettier' }
46
+ };
47
+
48
+ // ── Public helpers ──────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Walk up from `startDir` until a directory containing a known project
52
+ * root marker (package.json or formatter config) is found.
53
+ * Returns `startDir` as fallback when no marker exists above it.
54
+ *
55
+ * @param {string} startDir - Absolute directory path to start from
56
+ * @returns {string} Absolute path to the project root
57
+ */
58
+ function findProjectRoot(startDir) {
59
+ if (projectRootCache.has(startDir)) return projectRootCache.get(startDir);
60
+
61
+ let dir = startDir;
62
+ while (dir !== path.dirname(dir)) {
63
+ for (const marker of PROJECT_ROOT_MARKERS) {
64
+ if (fs.existsSync(path.join(dir, marker))) {
65
+ projectRootCache.set(startDir, dir);
66
+ return dir;
67
+ }
68
+ }
69
+ dir = path.dirname(dir);
70
+ }
71
+
72
+ projectRootCache.set(startDir, startDir);
73
+ return startDir;
74
+ }
75
+
76
+ /**
77
+ * Detect the formatter configured in the project.
78
+ * Biome takes priority over Prettier.
79
+ *
80
+ * @param {string} projectRoot - Absolute path to the project root
81
+ * @returns {'biome' | 'prettier' | null}
82
+ */
83
+ function detectFormatter(projectRoot) {
84
+ if (formatterCache.has(projectRoot)) return formatterCache.get(projectRoot);
85
+
86
+ for (const cfg of BIOME_CONFIGS) {
87
+ if (fs.existsSync(path.join(projectRoot, cfg))) {
88
+ formatterCache.set(projectRoot, 'biome');
89
+ return 'biome';
90
+ }
91
+ }
92
+
93
+ // Check package.json "prettier" key before config files
94
+ try {
95
+ const pkgPath = path.join(projectRoot, 'package.json');
96
+ if (fs.existsSync(pkgPath)) {
97
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
98
+ if ('prettier' in pkg) {
99
+ formatterCache.set(projectRoot, 'prettier');
100
+ return 'prettier';
101
+ }
102
+ }
103
+ } catch {
104
+ // Malformed package.json — continue to file-based detection
105
+ }
106
+
107
+ for (const cfg of PRETTIER_CONFIGS) {
108
+ if (fs.existsSync(path.join(projectRoot, cfg))) {
109
+ formatterCache.set(projectRoot, 'prettier');
110
+ return 'prettier';
111
+ }
112
+ }
113
+
114
+ formatterCache.set(projectRoot, null);
115
+ return null;
116
+ }
117
+
118
+ /**
119
+ * Resolve the runner binary and prefix args for the configured package
120
+ * manager (respects CLAUDE_PACKAGE_MANAGER env and project config).
121
+ *
122
+ * @param {string} projectRoot - Absolute path to the project root
123
+ * @returns {{ bin: string, prefix: string[] }}
124
+ */
125
+ function getRunnerFromPackageManager(projectRoot) {
126
+ const isWin = process.platform === 'win32';
127
+ const { getPackageManager } = require('./package-manager');
128
+ const pm = getPackageManager({ projectDir: projectRoot });
129
+ const execCmd = pm?.config?.execCmd || 'npx';
130
+ const [rawBin = 'npx', ...prefix] = execCmd.split(/\s+/).filter(Boolean);
131
+ const bin = isWin ? WIN_CMD_SHIMS[rawBin] || rawBin : rawBin;
132
+ return { bin, prefix };
133
+ }
134
+
135
+ /**
136
+ * Resolve the formatter binary, preferring the local node_modules/.bin
137
+ * installation over the package manager exec command to avoid
138
+ * package-resolution overhead.
139
+ *
140
+ * @param {string} projectRoot - Absolute path to the project root
141
+ * @param {'biome' | 'prettier'} formatter - Detected formatter name
142
+ * @returns {{ bin: string, prefix: string[] } | null}
143
+ * `bin` – executable path (absolute local path or runner binary)
144
+ * `prefix` – extra args to prepend (e.g. ['@biomejs/biome'] when using npx)
145
+ */
146
+ function resolveFormatterBin(projectRoot, formatter) {
147
+ const cacheKey = `${projectRoot}:${formatter}`;
148
+ if (binCache.has(cacheKey)) return binCache.get(cacheKey);
149
+
150
+ const pkg = FORMATTER_PACKAGES[formatter];
151
+ if (!pkg) {
152
+ binCache.set(cacheKey, null);
153
+ return null;
154
+ }
155
+
156
+ const isWin = process.platform === 'win32';
157
+ const localBin = path.join(projectRoot, 'node_modules', '.bin', isWin ? `${pkg.binName}.cmd` : pkg.binName);
158
+
159
+ if (fs.existsSync(localBin)) {
160
+ const result = { bin: localBin, prefix: [] };
161
+ binCache.set(cacheKey, result);
162
+ return result;
163
+ }
164
+
165
+ const runner = getRunnerFromPackageManager(projectRoot);
166
+ const result = { bin: runner.bin, prefix: [...runner.prefix, pkg.pkgName] };
167
+ binCache.set(cacheKey, result);
168
+ return result;
169
+ }
170
+
171
+ /**
172
+ * Clear all caches. Useful for testing.
173
+ */
174
+ function clearCaches() {
175
+ projectRootCache.clear();
176
+ formatterCache.clear();
177
+ binCache.clear();
178
+ }
179
+
180
+ module.exports = {
181
+ findProjectRoot,
182
+ detectFormatter,
183
+ resolveFormatterBin,
184
+ clearCaches
185
+ };