claude-measure-marker.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const os = require('os');
  4. const path = require('path');
  5. const { spawnSync } = require('child_process');
  6. const SOURCE_ROOT = path.resolve(__dirname, '..');
  7. const WORKSPACE_ROOT = process.cwd();
  8. const DEFAULT_TARGET = path.join(os.homedir(), '.claude', 'plugins', 'measurement-marker-check');
  9. const WORKSPACE_TARGET = path.join(WORKSPACE_ROOT, '.claude', 'plugins', 'measurement-marker-check');
  10. const WORKSPACE_PLUGINS_ROOT = path.join(WORKSPACE_ROOT, '.claude', 'plugins');
  11. const COPY_ENTRIES = [
  12. '.claude-plugin',
  13. 'README.md',
  14. 'scripts',
  15. 'skill-package-manifest.json',
  16. 'skills',
  17. 'test-fixtures',
  18. 'tools',
  19. 'package.json'
  20. ];
  21. function expandHome(value) {
  22. return String(value || '').replace(/^~(?=$|[\\/])/, os.homedir());
  23. }
  24. function parseArgs(argv) {
  25. const first = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'install';
  26. const args = {
  27. command: first,
  28. target: DEFAULT_TARGET,
  29. smoke: false,
  30. force: false,
  31. help: false
  32. };
  33. if (first === 'workspace' || first === 'install-workspace') {
  34. args.command = 'install';
  35. args.target = WORKSPACE_TARGET;
  36. }
  37. for (let i = first === argv[0] ? 1 : 0; i < argv.length; i++) {
  38. const token = argv[i];
  39. if (token === '--target' && argv[i + 1]) {
  40. args.target = argv[++i];
  41. } else if (token.startsWith('--target=')) {
  42. args.target = token.slice('--target='.length);
  43. } else if (token === '--workspace') {
  44. args.target = WORKSPACE_TARGET;
  45. } else if (token === '--smoke') {
  46. args.smoke = true;
  47. } else if (token === '--force') {
  48. args.force = true;
  49. } else if (token === '--help' || token === '-h') {
  50. args.help = true;
  51. }
  52. }
  53. args.target = path.resolve(expandHome(args.target));
  54. return args;
  55. }
  56. function printHelp() {
  57. console.log([
  58. 'Claude measurement marker skill package installer',
  59. '',
  60. 'Usage:',
  61. ' claude-measure-marker install [--target <dir>] [--smoke]',
  62. ' claude-measure-marker workspace [--smoke]',
  63. ' claude-measure-marker install --workspace [--smoke]',
  64. ' claude-measure-marker check',
  65. ' claude-measure-marker smoke',
  66. ' claude-measure-marker path',
  67. '',
  68. 'npx:',
  69. ' npx @gangvy/claude-code-measurement-marker-check install',
  70. ' npx @gangvy/claude-code-measurement-marker-check workspace --smoke',
  71. '',
  72. 'Options:',
  73. ' --workspace Install into ./.claude/plugins/measurement-marker-check',
  74. ' --target <dir> Install into a custom directory',
  75. ' --force Allow overwriting an existing custom target',
  76. ' --smoke Run sample smoke checks after install',
  77. ' --help, -h Show help'
  78. ].join('\n'));
  79. }
  80. function ensureDir(dirPath) {
  81. fs.mkdirSync(dirPath, { recursive: true });
  82. }
  83. function isInside(parentDir, childDir) {
  84. const relative = path.relative(path.resolve(parentDir), path.resolve(childDir));
  85. return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
  86. }
  87. function isSafeDefaultTarget(targetDir) {
  88. return path.resolve(targetDir) === path.resolve(DEFAULT_TARGET);
  89. }
  90. function isSafeWorkspaceTarget(targetDir) {
  91. return isInside(WORKSPACE_PLUGINS_ROOT, targetDir);
  92. }
  93. function canOverwriteTarget(targetDir, force) {
  94. return force || isSafeDefaultTarget(targetDir) || isSafeWorkspaceTarget(targetDir);
  95. }
  96. function copyDirRecursive(source, destination) {
  97. const stat = fs.statSync(source);
  98. if (stat.isDirectory()) {
  99. ensureDir(destination);
  100. for (const child of fs.readdirSync(source)) {
  101. if (child === 'node_modules' || child === 'outputs' || child === '.git') continue;
  102. copyDirRecursive(path.join(source, child), path.join(destination, child));
  103. }
  104. return;
  105. }
  106. ensureDir(path.dirname(destination));
  107. fs.copyFileSync(source, destination);
  108. }
  109. function copyPlugin(target, force) {
  110. if (fs.existsSync(target)) {
  111. if (!canOverwriteTarget(target, force)) {
  112. throw new Error(`Refusing to overwrite custom target without --force: ${target}`);
  113. }
  114. fs.rmSync(target, { recursive: true, force: true });
  115. }
  116. ensureDir(target);
  117. for (const entry of COPY_ENTRIES) {
  118. const source = path.join(SOURCE_ROOT, entry);
  119. if (!fs.existsSync(source)) continue;
  120. copyDirRecursive(source, path.join(target, entry));
  121. }
  122. }
  123. function checkPlugin(target) {
  124. const required = [
  125. '.claude-plugin/plugin.json',
  126. 'skills/measurement-marker-check/SKILL.md',
  127. 'tools/measurement-marker-check-run.js',
  128. 'scripts/smoke.js',
  129. 'package.json'
  130. ];
  131. const missing = required.filter(entry => !fs.existsSync(path.join(target, entry)));
  132. if (missing.length) {
  133. throw new Error(`Install target is missing required files: ${missing.join(', ')}`);
  134. }
  135. return {
  136. status: 'ok',
  137. target,
  138. required
  139. };
  140. }
  141. function asForwardSlash(filePath) {
  142. return path.resolve(filePath).replace(/\\/g, '/');
  143. }
  144. function quotedPath(filePath) {
  145. return `"${asForwardSlash(filePath)}"`;
  146. }
  147. function workspaceSkillText(target) {
  148. const runner = path.join(target, 'tools', 'measurement-marker-check-run.js');
  149. return [
  150. '---',
  151. 'name: measurement-marker-check',
  152. 'description: Check 装修量尺图、手绘量尺单、户型草图或现场测量图片中是否存在疑似未标记位置,输出缺标记清单、待复核区域和下一步补标建议。Use when the user asks for 实战二、装修量尺、量尺图识别、尺寸图缺标记、哪里没标、未标注检查 or measurement drawing review.',
  153. 'allowed-tools: Read Write Bash(node *)',
  154. '---',
  155. '',
  156. '# 装修量尺图缺标记检查官',
  157. '',
  158. '你是面向设计/量尺团队的量尺图缺标记检查官。用户要的是业务复核清单,不是技术日志。',
  159. '',
  160. '## 工作流',
  161. '',
  162. '1. 如果用户没有上传图片或给出图片路径,先请用户上传量尺图、手绘量尺单、户型草图或现场测量截图。',
  163. '2. 如果用户上传了图片,先直接观察图片内容,整理已标记内容、疑似缺标位置、待人工复核区域。',
  164. '3. 把观察结果写成结构化 JSON,保存到 `outputs/measurement-marker-check/input.json`。',
  165. '4. 调用本地工具生成稳定报告。',
  166. '5. 优先把工具返回的 `assistantMessage` 正文发给用户,文件路径只放末尾作为归档信息。',
  167. '',
  168. '## 检查口径',
  169. '',
  170. '- 空间名称:客厅、卧室、厨房、卫生间、阳台等。',
  171. '- 关键尺寸:长、宽、高、门洞、窗洞、墙面、柜体位置等。',
  172. '- 门窗洞口:宽高、离地高度、开启方向、门套或窗台限制。',
  173. '- 梁柱/墙体限制:梁高、柱位、墙厚、承重/非承重提示。',
  174. '- 水电/管道/烟道:下水、地漏、插座、开关、燃气、烟道、排水管。',
  175. '- 安装避让备注:需避让、不可拆、需复核、现场限制、手写备注。',
  176. '',
  177. '## 工具输入 JSON 结构',
  178. '',
  179. '```json',
  180. '{',
  181. ' "project": "项目或样本名称",',
  182. ' "image": "图片文件名或路径",',
  183. ' "detectedMarkers": [',
  184. ' { "area": "厨房右侧墙面", "markerType": "关键尺寸", "value": "3200mm", "evidence": "墙面横向尺寸已标注", "confidence": 0.9 }',
  185. ' ],',
  186. ' "missingMarkers": [',
  187. ' { "area": "窗户下沿", "missingType": "窗台高度", "risk": "可能影响台面、吊柜或窗帘盒设计", "evidence": "窗户位置已画出,但没有离地高度标注", "confidence": 0.78 }',
  188. ' ],',
  189. ' "uncertainAreas": [',
  190. ' { "area": "厨房右上角", "reason": "疑似烟道或立管,但文字不清晰", "action": "请现场照片或原始量尺单复核", "confidence": 0.58 }',
  191. ' ],',
  192. ' "overallJudgement": "存在关键缺标记,建议人工复核后再进入标准尺寸表。"',
  193. '}',
  194. '```',
  195. '',
  196. '## 生成报告命令',
  197. '',
  198. '```bash',
  199. `node ${quotedPath(runner)} --input "outputs/measurement-marker-check/input.json" --output "outputs/measurement-marker-check" --result-prefix MEASURE_MARKER_RESULT`,
  200. '```',
  201. '',
  202. '## 演示样例命令',
  203. '',
  204. '```bash',
  205. `node ${quotedPath(runner)} --sample --output "outputs/measurement-marker-check-sample" --result-prefix MEASURE_MARKER_RESULT`,
  206. '```',
  207. '',
  208. '## 输出规则',
  209. '',
  210. '- 先给结论:是否存在疑似缺标记。',
  211. '- 必须明确“区域 + 缺失类型 + 风险 + 图像依据 + 置信度”。',
  212. '- 看不清的地方归入“待人工复核区域”,不要强行判断。',
  213. '- 不要把 P0 结果说成最终工程结论;它是辅助复核清单。',
  214. '- 不要只返回文件路径。',
  215. '',
  216. '## 推荐启动话术',
  217. '',
  218. '```text',
  219. '帮我检查这张装修量尺图有没有哪里未标记。',
  220. '重点看空间名称、门窗洞口、长宽高、管道、插座/烟道和现场限制备注。',
  221. '先给我缺标记清单和人工复核建议。',
  222. '```',
  223. ''
  224. ].join('\n');
  225. }
  226. function writeWorkspaceSkillEntry(target) {
  227. if (!isSafeWorkspaceTarget(target)) return;
  228. const skillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'measurement-marker-check');
  229. ensureDir(skillDir);
  230. fs.writeFileSync(path.join(skillDir, 'SKILL.md'), workspaceSkillText(target), 'utf8');
  231. console.log('Wrote workspace skill entry: .\\.claude\\skills\\measurement-marker-check\\SKILL.md');
  232. }
  233. function runSmoke(target) {
  234. const result = spawnSync(process.execPath, ['scripts/smoke.js'], {
  235. cwd: target,
  236. stdio: 'inherit',
  237. shell: false
  238. });
  239. if (result.status !== 0) {
  240. throw new Error('smoke failed');
  241. }
  242. }
  243. function printNextSteps(target) {
  244. const workspaceMode = isSafeWorkspaceTarget(target);
  245. console.log('');
  246. console.log('Install complete.');
  247. console.log('');
  248. if (workspaceMode) {
  249. console.log('VSCode workspace mode is ready.');
  250. console.log('');
  251. console.log('Generated project files:');
  252. console.log(' .\\.claude\\skills\\measurement-marker-check\\SKILL.md');
  253. console.log(' .\\.claude\\plugins\\measurement-marker-check');
  254. console.log('');
  255. console.log('Restart the VSCode Claude Code session if it was already open.');
  256. } else {
  257. console.log('Claude Code CLI launch command:');
  258. console.log(` claude --plugin-dir "${target}"`);
  259. }
  260. console.log('');
  261. console.log('Try this prompt in Claude Code:');
  262. console.log(' 帮我检查这张装修量尺图有没有哪里未标记。');
  263. console.log(' 重点看空间名称、门窗洞口、长宽高、管道、插座/烟道和现场限制备注。');
  264. console.log(' 先给我缺标记清单和人工复核建议。');
  265. console.log('');
  266. console.log(`Install target: ${target}`);
  267. }
  268. function main() {
  269. const args = parseArgs(process.argv.slice(2));
  270. if (args.help || args.command === 'help') {
  271. printHelp();
  272. return;
  273. }
  274. if (args.command === 'path') {
  275. console.log(args.target);
  276. return;
  277. }
  278. if (args.command === 'install') {
  279. copyPlugin(args.target, args.force);
  280. const check = checkPlugin(args.target);
  281. console.log(JSON.stringify(check, null, 2));
  282. writeWorkspaceSkillEntry(args.target);
  283. if (args.smoke) runSmoke(args.target);
  284. printNextSteps(args.target);
  285. return;
  286. }
  287. if (args.command === 'check') {
  288. console.log(JSON.stringify(checkPlugin(args.target), null, 2));
  289. return;
  290. }
  291. if (args.command === 'smoke') {
  292. runSmoke(SOURCE_ROOT);
  293. return;
  294. }
  295. printHelp();
  296. process.exitCode = 1;
  297. }
  298. try {
  299. main();
  300. } catch (error) {
  301. console.error(`claude-measure-marker failed: ${error.message}`);
  302. process.exit(1);
  303. }