chill-agent 1.5.0 → 1.6.1

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 (188) hide show
  1. package/.claude-plugin/contrast-mcp-launcher.mjs +31 -0
  2. package/.claude-plugin/plugin.json +8 -3
  3. package/.cline-plugin/plugin.js +6 -0
  4. package/.codex-plugin/plugin.json +4 -3
  5. package/.cursor-plugin/marketplace.json +16 -0
  6. package/.cursor-plugin/plugin.json +2 -2
  7. package/.devin-plugin/plugin.json +2 -2
  8. package/.kimi-plugin/plugin.json +11 -29
  9. package/.omp-plugin/marketplace.json +15 -0
  10. package/.openclaw/skills/ponytail/SKILL.md +2 -7
  11. package/.openclaw/skills/ponytail-audit/SKILL.md +3 -6
  12. package/.openclaw/skills/ponytail-debt/SKILL.md +3 -4
  13. package/.openclaw/skills/ponytail-gain/SKILL.md +3 -4
  14. package/.openclaw/skills/ponytail-help/SKILL.md +3 -3
  15. package/.openclaw/skills/ponytail-review/SKILL.md +3 -6
  16. package/.opencode/plugins/ponytail.mjs +1 -1
  17. package/.qoder-plugin/plugin.json +12 -26
  18. package/AGENTS.md +32 -19
  19. package/__init__.py +1 -1
  20. package/assets/antislop-banner.png +0 -0
  21. package/assets/band/band.png +0 -0
  22. package/assets/band/board.png +0 -0
  23. package/assets/benchmark-3model.svg +21 -0
  24. package/assets/benchmark-agentic.svg +62 -0
  25. package/assets/codex-icon.png +0 -0
  26. package/assets/compare/code/after.webp +0 -0
  27. package/assets/compare/code/before.webp +0 -0
  28. package/assets/compare/text/after.webp +0 -0
  29. package/assets/compare/text/before.webp +0 -0
  30. package/assets/compare/ui/w-all.webp +0 -0
  31. package/assets/compare/ui/w-antislop.webp +0 -0
  32. package/assets/compare/ui/w-design.webp +0 -0
  33. package/assets/compare/ui/without.webp +0 -0
  34. package/assets/logo-dark.png +0 -0
  35. package/assets/logo-dark.svg +115 -0
  36. package/assets/logo-greenpt-dark.svg +27 -0
  37. package/assets/logo-greenpt.svg +27 -0
  38. package/assets/logo.png +0 -0
  39. package/assets/photoreunion-logo-dark.svg +4 -0
  40. package/assets/photoreunion-logo-light.svg +4 -0
  41. package/assets/profile.png +0 -0
  42. package/assets/retriever-logo-dark.svg +4 -0
  43. package/assets/retriever-logo-light.svg +4 -0
  44. package/assets/stories-banner.png +0 -0
  45. package/assets/v5/audit-es.png +0 -0
  46. package/assets/v5/audit-ja.png +0 -0
  47. package/assets/v5/audit-ko.png +0 -0
  48. package/assets/v5/audit-zh-CN.png +0 -0
  49. package/assets/v5/audit.png +0 -0
  50. package/assets/v5/beforeafter-es.png +0 -0
  51. package/assets/v5/beforeafter-ja.png +0 -0
  52. package/assets/v5/beforeafter-ko.png +0 -0
  53. package/assets/v5/beforeafter-zh-CN.png +0 -0
  54. package/assets/v5/beforeafter.png +0 -0
  55. package/assets/v5/chart-es.png +0 -0
  56. package/assets/v5/chart-ja.png +0 -0
  57. package/assets/v5/chart-ko.png +0 -0
  58. package/assets/v5/chart-zh-CN.png +0 -0
  59. package/assets/v5/chart.png +0 -0
  60. package/assets/v5/hero-es.jpg +0 -0
  61. package/assets/v5/hero-ja.jpg +0 -0
  62. package/assets/v5/hero-ko.jpg +0 -0
  63. package/assets/v5/hero-zh-CN.jpg +0 -0
  64. package/assets/v5/hero.jpg +0 -0
  65. package/assets/v5/ladder-es.png +0 -0
  66. package/assets/v5/ladder-ja.png +0 -0
  67. package/assets/v5/ladder-ko.png +0 -0
  68. package/assets/v5/ladder-zh-CN.png +0 -0
  69. package/assets/v5/ladder.png +0 -0
  70. package/assets/v5/review-es.png +0 -0
  71. package/assets/v5/review-ja.png +0 -0
  72. package/assets/v5/review-ko.png +0 -0
  73. package/assets/v5/review-zh-CN.png +0 -0
  74. package/assets/v5/review.png +0 -0
  75. package/assets/v5/tests-es.png +0 -0
  76. package/assets/v5/tests-ja.png +0 -0
  77. package/assets/v5/tests-ko.png +0 -0
  78. package/assets/v5/tests-zh-CN.png +0 -0
  79. package/assets/v5/tests.png +0 -0
  80. package/assets/waitlist-banner-es.png +0 -0
  81. package/assets/waitlist-banner-ja.png +0 -0
  82. package/assets/waitlist-banner-ko.png +0 -0
  83. package/assets/waitlist-banner-zh-CN.png +0 -0
  84. package/assets/waitlist-banner.png +0 -0
  85. package/benchmarks/.env.example +2 -0
  86. package/benchmarks/agentic/README.md +201 -0
  87. package/benchmarks/agentic/answer.py +288 -0
  88. package/benchmarks/agentic/compare.py +93 -0
  89. package/benchmarks/agentic/complete.py +178 -0
  90. package/benchmarks/agentic/judge.py +198 -0
  91. package/benchmarks/agentic/run.py +543 -0
  92. package/benchmarks/agentic/tasks.py +1544 -0
  93. package/benchmarks/arms/baseline.js +2 -0
  94. package/benchmarks/arms/caveman-SKILL.md +67 -0
  95. package/benchmarks/arms/caveman.js +8 -0
  96. package/benchmarks/arms/ponytail.js +8 -0
  97. package/benchmarks/behavior.js +54 -0
  98. package/benchmarks/behavior.yaml +40 -0
  99. package/benchmarks/correctness.js +291 -0
  100. package/benchmarks/loc.js +17 -19
  101. package/benchmarks/promptfooconfig.gemini.yaml +32 -0
  102. package/benchmarks/promptfooconfig.gpt-newest.yaml +33 -0
  103. package/benchmarks/promptfooconfig.yaml +41 -0
  104. package/benchmarks/results/2026-06-12-caveman-vs-ponytail.md +71 -0
  105. package/benchmarks/results/2026-06-12-v4-hardening-vs-caveman.md +125 -0
  106. package/benchmarks/results/2026-06-15-llama3.2-local.md +82 -0
  107. package/benchmarks/results/2026-06-16-correctness-gate-fix.md +113 -0
  108. package/benchmarks/results/2026-06-16-robustness-audit.md +135 -0
  109. package/benchmarks/results/2026-06-17-agentic-safety.md +164 -0
  110. package/benchmarks/results/2026-06-17-cost-verification.md +93 -0
  111. package/benchmarks/results/2026-06-18-agentic.md +219 -0
  112. package/benchmarks/results/2026-06-22-issue-245-217-comprehension.md +98 -0
  113. package/benchmarks/results/2026-10-07-agentic.md +186 -0
  114. package/bin/chill-agent.js +6 -6
  115. package/bin/chill-agent.mjs +7 -0
  116. package/cli/index.mjs +241 -0
  117. package/cli/lib/banner.mjs +21 -0
  118. package/cli/lib/install.mjs +498 -0
  119. package/cli/lib/settings.mjs +48 -0
  120. package/cli/package-lock.json +87 -0
  121. package/cli/package.json +42 -0
  122. package/cli/scripts/smoke-test.mjs +95 -0
  123. package/cli/scripts/smoke-worker.mjs +411 -0
  124. package/cli/scripts/sync-skills.mjs +81 -0
  125. package/examples/email-validation.md +156 -0
  126. package/examples/group-by.md +35 -0
  127. package/examples/infinite-scroll.md +58 -0
  128. package/examples/number-formatting.md +37 -0
  129. package/examples/rate-limit.md +272 -0
  130. package/examples/react-countdown.md +390 -0
  131. package/examples/url-params.md +41 -0
  132. package/gemini-extension.json +2 -2
  133. package/hooks/band/board.ts +560 -0
  134. package/hooks/band/logo.ts +3 -0
  135. package/hooks/band/metrics.ts +311 -0
  136. package/hooks/band/register.tsx +495 -0
  137. package/hooks/band/types.d.ts +34 -0
  138. package/hooks/claude-band.json +6 -0
  139. package/hooks/claude-codex-hooks.json +34 -4
  140. package/hooks/copilot-hooks.json +12 -2
  141. package/hooks/cursor-hooks.json +2 -2
  142. package/hooks/ponytail-band-import.js +274 -0
  143. package/hooks/ponytail-config.js +175 -0
  144. package/hooks/ponytail-map.js +111 -0
  145. package/hooks/ponytail-subagent.js +94 -0
  146. package/hooks/qoder-hooks.json +19 -4
  147. package/i18n/README.ko.md +230 -0
  148. package/package.json +12 -7
  149. package/pi-extension/test/extension.test.js +367 -0
  150. package/pi-extension/test/helpers.test.js +179 -0
  151. package/plugin.yaml +21 -12
  152. package/rules/antislop.mdc +18 -0
  153. package/scripts/build-openclaw-skills.js +60 -0
  154. package/scripts/check-repo.mjs +389 -0
  155. package/scripts/publish-openclaw-skills.js +75 -0
  156. package/scripts/skill-index.mjs +81 -0
  157. package/skills/antislop/SKILL.md +700 -0
  158. package/skills/antislop/VERSION +1 -0
  159. package/skills/antislop-code/SKILL.md +128 -0
  160. package/skills/antislop-copywriting/SKILL.md +407 -0
  161. package/skills/antislop-human/LICENSE-web-quality-skills +21 -0
  162. package/skills/antislop-human/SKILL.md +204 -0
  163. package/skills/antislop-human/contrast-check.py +168 -0
  164. package/skills/antislop-human/contrast-mcp.py +121 -0
  165. package/skills/antislop-human/references/A11Y-PATTERNS.md +249 -0
  166. package/skills/antislop-human/references/WCAG.md +127 -0
  167. package/skills/antislop-layoutmobile/SKILL.md +170 -0
  168. package/skills/antislop-ui/LICENSE-web-quality-skills +21 -0
  169. package/skills/antislop-ui/SKILL.md +344 -0
  170. package/skills/antislop-ui/references/CORE-WEB-VITALS.md +181 -0
  171. package/skills/antislop-ui/references/WEB-BASELINE.md +28 -0
  172. package/skills/plugin.json +5 -0
  173. package/src/package.json +3 -0
  174. package/tests/activation-map.test.js +63 -0
  175. package/tests/band-import.test.js +111 -0
  176. package/tests/band.test.tsx +520 -0
  177. package/tests/behavior.test.js +80 -0
  178. package/tests/gemini-extension.test.js +2 -2
  179. package/tests/grok-plugin.test.js +1 -1
  180. package/tests/hermes-plugin.test.js +1 -1
  181. package/tests/hooks-windows.test.js +205 -0
  182. package/tests/kimi-plugin.test.js +1 -1
  183. package/tests/map.test.js +30 -0
  184. package/tests/package-scripts.test.js +35 -0
  185. package/tests/package.test.js +23 -0
  186. package/tests/uninstall.test.js +202 -0
  187. /package/tests/{manifests.test.js → manifests.test.mjs} +0 -0
  188. /package/tests/{rules.test.js → rules.test.mjs} +0 -0
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { spawnSync } from 'node:child_process'
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
9
+ const worker = path.join(__dirname, 'smoke-worker.mjs')
10
+
11
+ function tree(dir, prefix = '') {
12
+ const out = []
13
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
14
+ const p = path.join(dir, entry.name)
15
+ if (entry.isDirectory()) {
16
+ out.push(prefix + entry.name + '/')
17
+ out.push(...tree(p, prefix + ' '))
18
+ } else {
19
+ out.push(prefix + entry.name)
20
+ }
21
+ }
22
+ return out
23
+ }
24
+
25
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'antislop-smoke-'))
26
+
27
+ // Every child gets a throwaway home from launch: Bun's os.homedir() ignores a later HOME
28
+ // change, so the worker's global install and --update would write into the real home.
29
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'antislop-home-'))
30
+ const env = { ...process.env, HOME: home, USERPROFILE: home }
31
+ for (const k of ['KIMI_CODE_HOME', 'CLINE_DIR', 'XDG_DATA_HOME']) delete env[k]
32
+
33
+ const result = spawnSync(process.execPath, [worker], { cwd: tmp, env, encoding: 'utf8' })
34
+ console.log('--- worker output ---')
35
+ console.log(result.stdout.trim())
36
+ if (result.status !== 0) console.error('worker stderr:', result.stderr)
37
+
38
+ console.log('\n--- files written to temp project ---')
39
+ console.log(tree(tmp).join('\n'))
40
+
41
+ for (const name of ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md']) {
42
+ const entry = path.join(tmp, name)
43
+ if (!fs.existsSync(entry)) continue
44
+ console.log(`\n--- ${name} ---`)
45
+ console.log(fs.readFileSync(entry, 'utf8'))
46
+ }
47
+
48
+ const coreSkill = path.join(tmp, '.claude', 'skills', 'antislop', 'SKILL.md')
49
+ const coreInstalled = fs.existsSync(coreSkill)
50
+ console.log('\ncore SKILL.md exists:', coreInstalled)
51
+
52
+ // Every failure is collected here and decided before cleanup, so a failing run
53
+ // still leaves a tidy temp dir behind.
54
+ const reasons = []
55
+ if (result.status !== 0) reasons.push(`worker exited ${result.status}`)
56
+ if (!coreInstalled) reasons.push('core SKILL.md was not installed')
57
+
58
+ // The wizard needs a terminal. With stdin closed it used to print its prompts, install
59
+ // nothing, and still exit 0, so callers saw success. It has to refuse and say why.
60
+ // Loading it needs the cli's dependencies, so a bare checkout reports a skip instead.
61
+ const cliDeps = fs.existsSync(path.join(__dirname, '..', 'node_modules', '@clack'))
62
+ if (cliDeps) {
63
+ const invalidMode = spawnSync(process.execPath, [path.join(__dirname, '..', 'index.mjs'), '--mode', 'invalid'], {
64
+ cwd: tmp,
65
+ env,
66
+ encoding: 'utf8',
67
+ stdio: ['ignore', 'pipe', 'pipe'],
68
+ })
69
+ const cleanError = /^antislop: /.test(invalidMode.stderr) && !/\n\s+at /.test(invalidMode.stderr)
70
+ if (invalidMode.status !== 1 || !/Usage: antislop-ai --mode/.test(invalidMode.stderr) || !cleanError) {
71
+ reasons.push('the mode command must exit 1 on one clean line, not a stack trace')
72
+ }
73
+ const noTty = spawnSync(process.execPath, [path.join(__dirname, '..', 'index.mjs')], {
74
+ cwd: tmp,
75
+ env,
76
+ encoding: 'utf8',
77
+ stdio: ['ignore', 'pipe', 'pipe'],
78
+ })
79
+ const refused = noTty.status === 1 && /needs a terminal/.test(noTty.stderr)
80
+ console.log('no-terminal run: exit', noTty.status, '| refused:', refused)
81
+ if (!refused) reasons.push(`a no-terminal run should exit 1 and say why, got exit ${noTty.status}`)
82
+
83
+ } else {
84
+ console.log('no-terminal run: skipped, cli dependencies are not installed')
85
+ }
86
+
87
+ fs.rmSync(tmp, { recursive: true, force: true })
88
+ fs.rmSync(home, { recursive: true, force: true })
89
+ console.log('\ncleaned up temp project.')
90
+
91
+ if (reasons.length > 0) {
92
+ console.error('\nsmoke test failed: ' + reasons.join('; '))
93
+ process.exit(1)
94
+ }
95
+ console.log('smoke test passed.')
@@ -0,0 +1,411 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import assert from 'node:assert/strict'
5
+ import { configureMode, readSettings, resolveSettingsPath } from '../lib/settings.mjs'
6
+ import {
7
+ AGENTS,
8
+ CORE,
9
+ PLUGIN_DOORS,
10
+ skillSourceDir,
11
+ resolveTargets,
12
+ detectAgents,
13
+ detectConflicts,
14
+ detectDuplicateReads,
15
+ detectPluginDoors,
16
+ installSkills,
17
+ installedVersion,
18
+ updateAll,
19
+ updatePointers,
20
+ VERSION,
21
+ } from '../lib/install.mjs'
22
+
23
+ const skills = ['antislop', 'antislop-ui']
24
+
25
+ assert.equal(
26
+ resolveSettingsPath({ platform: 'linux', env: {}, home: '/home/ada' }),
27
+ '/home/ada/.config/antislop/settings.json'
28
+ )
29
+ assert.equal(
30
+ resolveSettingsPath({ platform: 'darwin', env: {}, home: '/Users/ada' }),
31
+ '/Users/ada/.config/antislop/settings.json'
32
+ )
33
+ assert.equal(
34
+ resolveSettingsPath({ platform: 'win32', env: { APPDATA: 'C:\\Users\\Ada\\AppData\\Roaming' }, home: 'C:\\Users\\Ada' }),
35
+ 'C:\\Users\\Ada\\AppData\\Roaming\\antislop\\settings.json'
36
+ )
37
+ assert.equal(
38
+ resolveSettingsPath({ platform: 'win32', env: {}, home: 'C:\\Users\\Ada' }),
39
+ 'C:\\Users\\Ada\\.config\\antislop\\settings.json'
40
+ )
41
+
42
+ const settingsFile = path.join(process.cwd(), 'preferences', 'settings.json')
43
+ assert.match(configureMode([], settingsFile), /ask \(default\)/)
44
+ assert.equal(fs.existsSync(settingsFile), false)
45
+ for (const mode of ['during', 'after', 'ask']) {
46
+ assert.match(configureMode([mode], settingsFile), new RegExp(`global mode: ${mode}`))
47
+ assert.equal(readSettings(settingsFile).mode, mode)
48
+ assert.match(configureMode([], settingsFile), new RegExp(`global mode: ${mode}`))
49
+ }
50
+ fs.writeFileSync(settingsFile, '{"other":true,"mode":"during"}')
51
+ configureMode(['after'], settingsFile)
52
+ assert.deepEqual(readSettings(settingsFile), { other: true, mode: 'after' })
53
+ assert.throws(() => configureMode(['invalid'], settingsFile), /Usage:/)
54
+ assert.throws(() => configureMode(['during', 'extra'], settingsFile), /Usage:/)
55
+ assert.equal(readSettings(settingsFile).mode, 'after')
56
+ for (const content of ['{broken', 'null', '[]', '"during"']) {
57
+ fs.writeFileSync(settingsFile, content)
58
+ // The refusal is right, but it has to name the way out: --mode is the only command
59
+ // that can fix this file, so a message with no instruction is a dead end.
60
+ assert.throws(() => configureMode(['during'], settingsFile), /Fix or delete/)
61
+ assert.equal(fs.readFileSync(settingsFile, 'utf8'), content)
62
+ }
63
+ for (const mode of ['invalid', null, 1]) {
64
+ fs.writeFileSync(settingsFile, JSON.stringify({ mode }))
65
+ assert.throws(() => configureMode([], settingsFile), /Invalid mode/)
66
+ configureMode(['during'], settingsFile)
67
+ assert.equal(readSettings(settingsFile).mode, 'during')
68
+ }
69
+ console.log('ok global mode preferences: persistence, validation, and preservation')
70
+
71
+ const failures = []
72
+
73
+ /** Prints the line this always printed and records whether it held, so one run
74
+ * reports every break rather than stopping at the first.
75
+ */
76
+ const check = (label, actual, expected) => {
77
+ const ok = JSON.stringify(actual) === JSON.stringify(expected)
78
+ console.log(ok ? `ok ${label}:` : `FAIL ${label}:`, actual, ok ? '' : `(expected ${JSON.stringify(expected)})`)
79
+ if (!ok) failures.push(label)
80
+ }
81
+
82
+ // Fresh state: nothing detected, and the default selection is every agent.
83
+ const fresh = detectAgents('project')
84
+ check('A detected (fresh project)', fresh, [])
85
+ const defaultTargets = resolveTargets('project')
86
+ console.log('A default targets:', defaultTargets.map((t) => `${t.agents.map((a) => a.id).join('+')}@${t.path} exists=${t.exists}`).join(' | '))
87
+ // Copilot, Kimi Code, and Amp share Antigravity's folder, so twelve resolve to nine.
88
+ check('A twelve agents over nine folders', [AGENTS.length, defaultTargets.length], [12, 9])
89
+ check('A copilot and kimi share the antigravity folder', resolveTargets('project', ['antigravity', 'copilot', 'kimi']).length, 1)
90
+
91
+ // Claude Code only, via explicit selection (old behavior preserved).
92
+ const targets = resolveTargets('project', ['claude'])
93
+ let written = installSkills({ skills, targets, overwrite: false })
94
+ let pointers = updatePointers({ targets, skills })
95
+ console.log('B targets:', targets.map((t) => `${t.agents.map((a) => a.id).join('+')}@${t.path} exists=${t.exists}`).join(' | '))
96
+ console.log('B written:', written.map((w) => `${w.agents.join('+')}:${w.skill}`).join(', '))
97
+ check('B pointers', pointers.map((p) => path.basename(p)), ['CLAUDE.md'])
98
+ const pointer = fs.readFileSync(pointers[0], 'utf8')
99
+ assert.match(pointer, /explicit session instruction first, then global preference, then ask/)
100
+ assert.match(pointer, /A session instruction always wins/)
101
+ assert.ok(pointer.includes('antislop active: <mode> (session override).'))
102
+ assert.ok(pointer.includes('antislop active: <mode> (global preference).'))
103
+ assert.ok(pointer.includes('ask during/after and end the response'))
104
+ assert.ok(pointer.includes('only at the start of the final answer, never in progress messages'))
105
+ assert.ok(pointer.includes('announce before the first edit and omit it from the final answer'))
106
+ assert.ok(pointer.includes('`.claude/skills/antislop/SKILL.md`'))
107
+ assert.ok(pointer.includes('`.claude/skills/antislop-ui/SKILL.md`'))
108
+
109
+ const conflicts = detectConflicts({ skills, targets })
110
+ written = installSkills({ skills, targets, overwrite: false })
111
+ check('C conflicts', conflicts.length, 2)
112
+ check('C written without overwrite', written.length, 0)
113
+ written = installSkills({ skills, targets, overwrite: true })
114
+ check('C overwritten', written.length, 2)
115
+
116
+ // Antigravity on a fresh project: .agents/ does not exist yet, install creates it.
117
+ const agTargets = resolveTargets('project', ['antigravity'])
118
+ console.log('D antigravity targets:', agTargets.map((t) => `${t.agents.map((a) => a.id).join('+')}@${t.path} exists=${t.exists}`).join(' | '), '(exists=false before install)')
119
+ const agWritten = installSkills({ skills, targets: agTargets, overwrite: false })
120
+ check('D antigravity written', agWritten.length, 2)
121
+ const agPointers = updatePointers({ targets: agTargets, skills })
122
+ check('D antigravity pointers', agPointers.map((p) => path.basename(p)), ['AGENTS.md'])
123
+ check('D .agents/skills/antislop/SKILL.md exists', fs.existsSync(path.join(process.cwd(), '.agents', 'skills', 'antislop', 'SKILL.md')), true)
124
+
125
+ // OpenCode, Cursor, and Gemini each install and point to their own entry file.
126
+ for (const agent of ['opencode', 'cursor', 'gemini']) {
127
+ const t = resolveTargets('project', [agent])
128
+ const w = installSkills({ skills, targets: t, overwrite: false })
129
+ const pointers = updatePointers({ targets: t, skills })
130
+ check(`D2 ${agent} written`, w.length, 2)
131
+ check(`D2 ${agent} pointer`, pointers.map((p) => path.basename(p)), [agent === 'gemini' ? 'GEMINI.md' : 'AGENTS.md'])
132
+ check(`D2 ${agent} folder exists`, fs.existsSync(path.join(process.cwd(), agent === 'gemini' ? '.gemini' : agent === 'cursor' ? '.cursor' : '.opencode', 'skills', 'antislop', 'SKILL.md')), true)
133
+ }
134
+
135
+ // Cline keeps a folder of its own and reads .claude/skills beside it, so both fill up.
136
+ const clineProject = resolveTargets('project', ['cline'])
137
+ check('D8 cline project target', clineProject[0].path, path.join(process.cwd(), '.cline', 'skills'))
138
+ check('D8 cline global target', resolveTargets('global', ['cline'])[0].path, path.join(os.homedir(), '.cline', 'skills'))
139
+ check('D8 cline written', installSkills({ skills, targets: clineProject, overwrite: false }).length, 2)
140
+ check('D8 cline pointer', updatePointers({ targets: clineProject, skills }).map((p) => path.basename(p)), ['AGENTS.md'])
141
+ check('D8 cline folder exists', fs.existsSync(path.join(process.cwd(), '.cline', 'skills', 'antislop', 'SKILL.md')), true)
142
+
143
+ // Amp shares the project's .agents/skills, which the Antigravity block already filled,
144
+ // but its global folder is a different path.
145
+ const ampProject = resolveTargets('project', ['amp'])
146
+ check('D9 amp shares the agents folder', ampProject[0].path, path.join(process.cwd(), '.agents', 'skills'))
147
+ check('D9 amp global target', resolveTargets('global', ['amp'])[0].path, path.join(os.homedir(), '.config', 'agents', 'skills'))
148
+ check('D9 amp writes nothing twice', installSkills({ skills, targets: ampProject, overwrite: false }).length, 0)
149
+ check('D9 amp pointer', updatePointers({ targets: ampProject, skills }).map((p) => path.basename(p)), ['AGENTS.md'])
150
+
151
+ // Pi keeps a folder of its own at both scopes, and walks the shared .agents/skills
152
+ // beside it, which the Antigravity block already filled.
153
+ const piProject = resolveTargets('project', ['pi'])
154
+ check('D10 pi project target', piProject[0].path, path.join(process.cwd(), '.pi', 'skills'))
155
+ check('D10 pi global target', resolveTargets('global', ['pi'])[0].path, path.join(os.homedir(), '.pi', 'agent', 'skills'))
156
+ check('D10 pi written', installSkills({ skills, targets: piProject, overwrite: false }).length, 2)
157
+ check('D10 pi pointer', updatePointers({ targets: piProject, skills }).map((p) => path.basename(p)), ['AGENTS.md'])
158
+ check('D10 pi folder exists', fs.existsSync(path.join(process.cwd(), '.pi', 'skills', 'antislop', 'SKILL.md')), true)
159
+ // Pi loads the shared folder too, so Pi plus Antigravity holds the same names twice.
160
+ check('D10 pi duplicate read named', detectDuplicateReads({ targets: resolveTargets('project', ['antigravity', 'pi']), location: 'project' }).map((d) => [d.agent.id, d.paths.length]), [['pi', 2]])
161
+
162
+ // Hermes reads a project's .hermes/skills, and ~/.hermes/skills for a global install.
163
+ const hermesProject = resolveTargets('project', ['hermes'])
164
+ const hermesGlobal = resolveTargets('global', ['hermes'])
165
+ check('D3 hermes project target', hermesProject[0].path, path.join(process.cwd(), '.hermes', 'skills'))
166
+ check('D3 hermes global target', hermesGlobal[0].path, path.join(os.homedir(), '.hermes', 'skills'))
167
+ const hermesWritten = installSkills({ skills, targets: hermesProject, overwrite: false })
168
+ check('D3 hermes written into the project', hermesWritten.length, 2)
169
+ check('D3 hermes detected in project', detectAgents('project').includes('hermes'), true)
170
+
171
+ // OpenCode splits its scopes: project folder for a project install, ~/.config for global.
172
+ const ocProject = resolveTargets('project', ['opencode'])
173
+ const ocGlobal = resolveTargets('global', ['opencode'])
174
+ check('D4 opencode project target', ocProject[0].path, path.join(process.cwd(), '.opencode', 'skills'))
175
+ check('D4 opencode global target', ocGlobal[0].path, path.join(os.homedir(), '.config', 'opencode', 'skills'))
176
+
177
+ // Antigravity reads a project's .agents/skills but not the one under the home dir.
178
+ const agGlobal = resolveTargets('global', ['antigravity'])
179
+ check('D5 antigravity project target', resolveTargets('project', ['antigravity'])[0].path, path.join(process.cwd(), '.agents', 'skills'))
180
+ check('D5 antigravity global target', agGlobal[0].path, path.join(os.homedir(), '.gemini', 'config', 'skills'))
181
+
182
+ // Kimi Code has no folder of its own here: both scopes are the shared .agents/skills.
183
+ check('D7 kimi project target', resolveTargets('project', ['kimi'])[0].path, path.join(process.cwd(), '.agents', 'skills'))
184
+
185
+ // updatePointers has no other source for the entry file, so no row may omit it.
186
+ check('D6 every agent names an entry file', AGENTS.filter((a) => !a.entry).map((a) => a.id), [])
187
+
188
+ // Hermes reads a project's AGENTS.md, so a project install of it must write the pointer.
189
+ check('D6 hermes writes a project pointer', updatePointers({ targets: hermesProject, skills }).map((p) => path.basename(p)), ['AGENTS.md'])
190
+
191
+ // Detection now sees the agents that were installed.
192
+ const after = detectAgents('project')
193
+ check('E detected after installs', [...after].sort(), ['amp', 'antigravity', 'claude', 'cline', 'copilot', 'cursor', 'gemini', 'hermes', 'kimi', 'opencode', 'pi'])
194
+
195
+ // OpenCode reads .claude/skills and .agents/skills too, so a project that installs into
196
+ // two of them holds the same names twice and OpenCode picks between them unpredictably.
197
+ const dupReads = detectDuplicateReads({ targets: resolveTargets('project', ['claude', 'opencode']), location: 'project' })
198
+ check('E duplicate read named', dupReads.map((d) => [d.agent.id, d.paths.length]), [['opencode', 2]])
199
+ check('E one folder alone is not a duplicate', detectDuplicateReads({ targets: resolveTargets('project', ['opencode']), location: 'project' }), [])
200
+ // Cline reads .claude/skills beside its own folder, so Claude Code plus Cline collides.
201
+ check('E cline duplicate read named', detectDuplicateReads({ targets: resolveTargets('project', ['claude', 'cline']), location: 'project' }).map((d) => [d.agent.id, d.paths.length]), [['cline', 2]])
202
+ // Amp loads .claude/skills beside the shared folder, so Claude Code plus Amp collides too.
203
+ check('E amp duplicate read named', detectDuplicateReads({ targets: resolveTargets('project', ['claude', 'amp']), location: 'project' }).map((d) => [d.agent.id, d.paths.length]), [['amp', 2]])
204
+ // Copilot reads .claude/skills beside the shared folder, so Claude Code plus Copilot collides.
205
+ check('E copilot duplicate read named', detectDuplicateReads({ targets: resolveTargets('project', ['claude', 'copilot']), location: 'project' }).map((d) => [d.agent.id, d.paths.length]), [['copilot', 2]])
206
+ check('E global scope is not checked', detectDuplicateReads({ targets: resolveTargets('global', ['claude', 'opencode']), location: 'global' }), [])
207
+
208
+ // Codex's global scope is the shared folder, not ~/.codex/skills, which Codex calls deprecated.
209
+ // Kimi Code lands there too, so the grouping has to hold at global scope as well.
210
+ const globalTargets = resolveTargets('global', ['claude', 'codex', 'kimi'])
211
+ check('F global targets', globalTargets.map((t) => `${t.agents.map((a) => a.id).join('+')}@${t.path}`), [
212
+ `claude@${path.join(os.homedir(), '.claude', 'skills')}`,
213
+ `codex+kimi@${path.join(os.homedir(), '.agents', 'skills')}`,
214
+ ])
215
+
216
+ // Copies are identical and the pointer block dedupes.
217
+ // A shared entry file must point to an installed copy, even with several agents.
218
+ const sharedTargets = resolveTargets('project', ['codex', 'opencode'])
219
+ installSkills({ skills, targets: sharedTargets, overwrite: false })
220
+ updatePointers({ targets: sharedTargets, skills })
221
+ const sharedPointer = fs.readFileSync('AGENTS.md', 'utf8')
222
+ for (const skill of skills) {
223
+ const installedPath = `.codex/skills/${skill}/SKILL.md`
224
+ assert.ok(sharedPointer.includes(`\`${installedPath}\``))
225
+ assert.equal(fs.readFileSync(installedPath, 'utf8'), fs.readFileSync(path.join(skillSourceDir(), skill, 'SKILL.md'), 'utf8'))
226
+ }
227
+
228
+ const src = fs.readFileSync(path.join(skillSourceDir(), 'antislop-ui', 'SKILL.md'), 'utf8')
229
+ const dst = fs.readFileSync(path.join(process.cwd(), '.claude', 'skills', 'antislop-ui', 'SKILL.md'), 'utf8')
230
+ check('G antislop-ui SKILL.md identical', src === dst, true)
231
+
232
+ // The conflict prompt reports the release on disk from this file alone.
233
+ check('G installed version is stamped', installedVersion(targets[0].path), VERSION)
234
+ check('G no install means no version', installedVersion(path.join(process.cwd(), 'nowhere')), null)
235
+
236
+ updatePointers({ targets, skills })
237
+ const entry = fs.readFileSync(path.join(process.cwd(), 'CLAUDE.md'), 'utf8')
238
+ check('H blocks after a second run', (entry.match(/antislop:start/g) || []).length, 1)
239
+
240
+ // The entry file belongs to the author. Each fixture below used to be damaged.
241
+ const entryPath = path.join(process.cwd(), 'CLAUDE.md')
242
+ const write = (text) => fs.writeFileSync(entryPath, text)
243
+ const read = () => fs.readFileSync(entryPath, 'utf8')
244
+
245
+ write('# Mine\r\n\r\nNotes.\r\n')
246
+ updatePointers({ targets, skills })
247
+ check('I CRLF kept', read().includes('\r\n<!-- antislop:start -->'), true)
248
+ check('I bare LF count', (read().match(/(?<!\r)\n/g) || []).length, 0)
249
+
250
+ write('# Mine\n\n```md\n<!-- antislop:start -->\n<!-- antislop:end -->\n```\n')
251
+ updatePointers({ targets, skills })
252
+ check('I fenced example untouched', read().includes('```md\n<!-- antislop:start -->\n<!-- antislop:end -->\n```'), true)
253
+ check('I real block added beside it', (read().match(/antislop:start/g) || []).length, 2)
254
+
255
+ write('# Mine\n<!-- antislop:end -->\n')
256
+ updatePointers({ targets, skills })
257
+ const stable = read()
258
+ updatePointers({ targets, skills })
259
+ check('I orphan marker does not grow', read() === stable, true)
260
+ check('I orphan marker is gone', (read().match(/antislop:end/g) || []).length, 1)
261
+
262
+ // A fence left open at EOF is a typo, not a boundary: the block lands inside it and has
263
+ // to be found again, or every run appends another copy.
264
+ write('# Mine\n\nNotes:\n\n```bash\nnpm i\n')
265
+ updatePointers({ targets, skills })
266
+ const fenced = read()
267
+ updatePointers({ targets, skills })
268
+ updatePointers({ targets, skills })
269
+ check('J unclosed fence stable', read() === fenced, true)
270
+ check('J unclosed fence keeps code', read().includes('npm i'), true)
271
+ check('J unclosed fence one block', (read().match(/antislop:start/g) || []).length, 1)
272
+
273
+ // A mistyped end marker is the author's text, so the block is appended, not swapped in.
274
+ write('# Mine\n\n<!-- antislop:start -->\nold\n<!-- antislop:End -->\n\n## Notes\nKeep this line.\n')
275
+ updatePointers({ targets, skills })
276
+ check('J mistyped end keeps the tail', read().includes('Keep this line.'), true)
277
+ check('J mistyped end one block', (read().match(/antislop:start/g) || []).length, 1)
278
+
279
+ // A longer fence run is not closed by a shorter one inside the example.
280
+ write('# Mine\n\n````md\n```md\n<!-- antislop:start -->\n<!-- antislop:end -->\n```\n````\n')
281
+ updatePointers({ targets, skills })
282
+ check('J four-backtick example untouched', read().includes('````md\n```md\n<!-- antislop:start -->'), true)
283
+
284
+ // A fence-like line with trailing text is code, not a closing fence.
285
+ for (const fence of ['```', '~~~']) {
286
+ const example = `${fence}md\n${fence}js\n<!-- antislop:start -->\nKeep this example.\n<!-- antislop:end -->\n${fence}`
287
+ write(`# Mine\n\n${example}\n`)
288
+ updatePointers({ targets, skills })
289
+ check(`J ${fence} with info string keeps the example`, read().includes(example), true)
290
+ check(`J ${fence} real block added outside the example`, (read().match(/antislop:start/g) || []).length, 2)
291
+ const first = read()
292
+ updatePointers({ targets, skills })
293
+ check(`J ${fence} example stays intact on reinstall`, read() === first, true)
294
+ }
295
+
296
+ // A stray end marker below the block is ours too, and must not survive.
297
+ write('# Mine\n<!-- antislop:start -->\nold\n<!-- antislop:end -->\nTail text\n<!-- antislop:end -->\n')
298
+ updatePointers({ targets, skills })
299
+ check('J stray end below cleared', (read().match(/antislop:end/g) || []).length, 1)
300
+ check('J text below the block kept', read().includes('Tail text'), true)
301
+
302
+ // Dominant line ending wins, so two CRLF lines do not flip a mostly-LF file.
303
+ write('# Mine\n' + 'line\n'.repeat(10) + 'a\r\nb\r\n')
304
+ updatePointers({ targets, skills })
305
+ check('J mostly-LF stays LF', (read().match(/\r\n/g) || []).length, 0)
306
+
307
+ // A second marker pair leaves a stray start behind unless every marker line is cleared.
308
+ write('# Mine\n<!-- antislop:start -->\nX\n<!-- antislop:end -->\nKeep this too.\n<!-- antislop:start -->\nY\n<!-- antislop:end -->\n')
309
+ updatePointers({ targets, skills })
310
+ check('J duplicate pair settles to one', (read().match(/antislop:start/g) || []).length, 1)
311
+ check('J duplicate pair keeps text', read().includes('Keep this too.'), true)
312
+
313
+ // --update replaces what is already on disk, keeps each folder's skill selection, and
314
+ // names the release it replaced. The scope list keeps a test out of the home directory.
315
+ const present = resolveTargets('project').filter((t) => fs.existsSync(path.join(t.path, CORE)))
316
+ check('K update finds the installed folders', present.length > 0, true)
317
+ const upd = updateAll({ locations: ['project'] })
318
+ check('K update reports every folder it found', upd.results.length, present.length)
319
+ check('K update lands on this release', upd.results.every((r) => r.to === VERSION), true)
320
+ check('K update keeps the skill selection', upd.results.every((r) => r.skills.includes(CORE)), true)
321
+ check('K update writes a pointer', upd.pointers.length > 0, true)
322
+ check('K pointer carries the update line', fs.readFileSync(path.join(process.cwd(), 'AGENTS.md'), 'utf8').includes('antislop-ai --update'), true)
323
+ const again = updateAll({ locations: ['project'] })
324
+ check('K update is repeatable', again.results.map((r) => r.from), upd.results.map((r) => r.to))
325
+
326
+ // A clean directory has nothing to replace, and saying so is the whole answer.
327
+ const clean = fs.mkdtempSync(path.join(os.tmpdir(), 'antislop-clean-'))
328
+ const here = process.cwd()
329
+ process.chdir(clean)
330
+ check('K update says nothing when nothing is installed', updateAll({ locations: ['project'] }).results.length, 0)
331
+ check('K update writes no pointer when nothing is installed', updateAll({ locations: ['project'] }).pointers.length, 0)
332
+ process.chdir(here)
333
+ fs.rmSync(clean, { recursive: true, force: true })
334
+
335
+ // --update can combine folders with different extras. Every pointer must resolve,
336
+ // and a separate entry file must not list another agent's unavailable extras.
337
+ const mixed = fs.mkdtempSync(path.join(os.tmpdir(), 'antislop-mixed-'))
338
+ try {
339
+ process.chdir(mixed)
340
+ installSkills({ skills: [CORE], targets: resolveTargets('project', ['codex']), overwrite: false })
341
+ installSkills({ skills: [CORE, 'antislop-ui'], targets: resolveTargets('project', ['opencode']), overwrite: false })
342
+ installSkills({ skills: [CORE, 'antislop-copywriting'], targets: resolveTargets('project', ['claude']), overwrite: false })
343
+ updateAll({ locations: ['project'] })
344
+ const shared = fs.readFileSync('AGENTS.md', 'utf8')
345
+ const separate = fs.readFileSync('CLAUDE.md', 'utf8')
346
+ assert.ok(shared.includes('`.codex/skills/antislop/SKILL.md`'))
347
+ assert.ok(shared.includes('`.opencode/skills/antislop-ui/SKILL.md`'))
348
+ assert.equal(shared.includes('antislop-copywriting'), false)
349
+ assert.ok(separate.includes('`.claude/skills/antislop-copywriting/SKILL.md`'))
350
+ assert.equal(separate.includes('antislop-ui'), false)
351
+ for (const pointer of [shared, separate]) {
352
+ for (const match of pointer.matchAll(/`([^`]+\/SKILL\.md)`/g)) assert.ok(fs.existsSync(match[1]))
353
+ }
354
+ console.log('ok K mixed selections retain valid per-skill paths and separate entry files')
355
+ } finally {
356
+ process.chdir(here)
357
+ fs.rmSync(mixed, { recursive: true, force: true })
358
+ }
359
+
360
+ // A door dropped from the list stops being named after an install, and nothing else notices.
361
+ check(
362
+ 'K all seven plugin doors are probed',
363
+ PLUGIN_DOORS.map((d) => d.id).sort(),
364
+ ['antigravity', 'claude', 'cline', 'codex', 'cursor', 'kimi', 'omp']
365
+ )
366
+
367
+ // A door row without a command would print a blank line where the answer belongs.
368
+ check('K every plugin door names itself and its command', PLUGIN_DOORS.filter((d) => !d.id || !d.label || !d.update).map((d) => d.id), [])
369
+
370
+ // A store holding an old copy beside the new one used to report whichever folder listed
371
+ // first. The old copy is named to list first, so only a version sort reports 3.2.22.
372
+ // The home is the throwaway one smoke-test.mjs launched this worker with. Writing here
373
+ // outside that run would touch a real install, so refuse instead.
374
+ const fakeHome = os.homedir()
375
+ const inTmp = path.relative(fs.realpathSync(os.tmpdir()), fs.realpathSync(fakeHome))
376
+ if (inTmp.startsWith('..') || path.isAbsolute(inTmp)) {
377
+ console.error(`refusing to write into ${fakeHome}: run this worker through smoke-test.mjs`)
378
+ process.exit(1)
379
+ }
380
+ try {
381
+ const copy = (dir, version) => {
382
+ fs.mkdirSync(path.join(fakeHome, dir, 'skills', CORE), { recursive: true })
383
+ fs.writeFileSync(path.join(fakeHome, dir, 'skills', CORE, 'VERSION'), version + '\n')
384
+ }
385
+ for (const [name, version] of [['aa-old', '3.2.9'], ['zz-new', '3.2.22']]) {
386
+ copy(path.join('.kimi-code', 'plugins', 'managed', name), version)
387
+ copy(path.join('.cline', 'plugins', '_installed', name, 'hash'), version)
388
+ copy(path.join('.omp', 'plugins', name), version)
389
+ fs.mkdirSync(path.join(fakeHome, '.codex', 'plugins', 'cache', 'anti-slop', CORE, version), { recursive: true })
390
+ }
391
+ check(
392
+ 'L plugin doors report the newest copy in a store',
393
+ detectPluginDoors().map((d) => `${d.door.id} ${d.version}`).sort(),
394
+ ['cline 3.2.22', 'codex 3.2.22', 'kimi 3.2.22', 'omp 3.2.22']
395
+ )
396
+
397
+ // Run from home, --update must treat it as the global scope only.
398
+ installSkills({ skills: [CORE], targets: resolveTargets('global', ['claude']), overwrite: false })
399
+ process.chdir(fakeHome)
400
+ check('L update from home touches each folder once', updateAll().results.map((r) => r.location), ['global'])
401
+ check('L update from home writes no pointer', fs.existsSync(path.join(fakeHome, 'CLAUDE.md')), false)
402
+ check('L install from home writes no pointer', updatePointers({ targets: resolveTargets('project', ['claude']), skills: [CORE] }), [])
403
+ } finally {
404
+ process.chdir(here)
405
+ }
406
+
407
+ if (failures.length > 0) {
408
+ console.error(`\n${failures.length} check(s) failed: ${failures.join(', ')}`)
409
+ process.exit(1)
410
+ }
411
+ console.log('\nall checks passed')
@@ -0,0 +1,81 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
6
+ const repoRoot = path.join(__dirname, '..', '..')
7
+ const repoSkills = path.join(repoRoot, 'skills')
8
+ const cliSkills = path.join(__dirname, '..', 'skills')
9
+
10
+ const CORE_FRONTMATTER = [
11
+ '---',
12
+ 'name: antislop',
13
+ 'description: "Anti Slop: Rules for AI Coding Agents. The core filter. Load always to stop generic AI slop."',
14
+ 'allowed-tools: Read Write Edit Glob Grep',
15
+ '---',
16
+ '',
17
+ ].join('\n')
18
+
19
+ const version = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version
20
+
21
+ const coreBody = fs.readFileSync(path.join(repoRoot, 'antislop.md'), 'utf8')
22
+ .replace(/\r\n/g, '\n')
23
+ .trim()
24
+
25
+ // Snyk W012: a shipped skill must not tell the agent to download its own instructions.
26
+ if (/https?:\/\/raw\.githubusercontent\.com/.test(coreBody)) {
27
+ throw new Error('antislop.md carries a runtime download URL; remove it before syncing (Snyk W012)')
28
+ }
29
+
30
+ // The wizard installs the single-file route. Every route that ships this SKILL.md has already
31
+ // placed the folders, so the wizard would only cost context there; the update steps stay.
32
+ const WIZARD = /^## First-Run Install Wizard\n[\s\S]*?^### (Already installed, and the user asks how to update)$/m
33
+ if (!WIZARD.test(coreBody)) throw new Error('antislop.md: install wizard headings not found')
34
+ const skillBody = coreBody.replace(WIZARD, '## $1')
35
+ fs.writeFileSync(path.join(repoSkills, 'antislop', 'SKILL.md'), CORE_FRONTMATTER + skillBody + '\n')
36
+
37
+ // An installed copy is a snapshot with nothing in it that names the release it came from,
38
+ // so an update could not say what was on disk. Every install route copies this folder.
39
+ fs.writeFileSync(path.join(repoSkills, 'antislop', 'VERSION'), version + '\n')
40
+
41
+ // Python leaves a __pycache__ beside the contrast checker; it must not reach the tarball.
42
+ fs.rmSync(cliSkills, { recursive: true, force: true })
43
+ fs.cpSync(repoSkills, cliSkills, {
44
+ recursive: true,
45
+ filter: (src) => path.basename(src) !== '__pycache__',
46
+ })
47
+
48
+ // npm only ships a README and LICENSE found in cli/, so the package page and Socket saw none.
49
+ // Relative links point at files that are not in the tarball, so aim them at the repo.
50
+ const REPO = 'https://github.com/miqdadbadjuber/anti-slop'
51
+ const readme = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8')
52
+ .replace(/src="(?:\.\/)?(?!https?:|#)([^"]+)"/g, `src="${REPO}/raw/main/$1"`)
53
+ .replace(/href="(?:\.\/)?(?!https?:|#|mailto:)([^"]+)"/g, `href="${REPO}/blob/main/$1"`)
54
+ .replace(/\]\((?:\.\/)?(?!https?:|#|mailto:)([^)]+)\)/g, `](${REPO}/blob/main/$1)`)
55
+ fs.writeFileSync(path.join(__dirname, '..', 'README.md'), readme)
56
+ fs.copyFileSync(path.join(repoRoot, 'LICENSE'), path.join(__dirname, '..', 'LICENSE'))
57
+
58
+ // A Windows checkout hands back CRLF, and a shipped skill would then carry "name: x\r"
59
+ // into any registry that splits on \n alone. Ship LF whatever the checkout did.
60
+ const TEXT = new Set(['.md', '.py', '.json', '.txt', '.mjs'])
61
+ let normalised = 0
62
+ const normalise = (dir) => {
63
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
64
+ const file = path.join(dir, e.name)
65
+ if (e.isDirectory()) {
66
+ normalise(file)
67
+ continue
68
+ }
69
+ if (!TEXT.has(path.extname(e.name))) continue
70
+ const buf = fs.readFileSync(file)
71
+ if (!buf.includes(0x0d)) continue
72
+ fs.writeFileSync(file, buf.toString('utf8').replace(/\r\n/g, '\n'), 'utf8')
73
+ normalised++
74
+ }
75
+ }
76
+ normalise(cliSkills)
77
+
78
+ console.log(
79
+ `Regenerated skills/antislop/SKILL.md and VERSION (${version}) and synced to cli/skills/` +
80
+ (normalised ? ` (${normalised} file(s) normalised to LF)` : '')
81
+ )