#!/usr/bin/env node
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const SOURCE_ROOT = path.resolve(__dirname, '..');
const WORKSPACE_ROOT = process.cwd();
const DEFAULT_TARGET = path.join(os.homedir(), '.claude', 'plugins', 'measurement-marker-check');
const WORKSPACE_TARGET = path.join(WORKSPACE_ROOT, '.claude', 'plugins', 'measurement-marker-check');
const WORKSPACE_PLUGINS_ROOT = path.join(WORKSPACE_ROOT, '.claude', 'plugins');
const COPY_ENTRIES = [
'.claude-plugin',
'README.md',
'scripts',
'skill-package-manifest.json',
'skills',
'test-fixtures',
'tools',
'package.json'
];
function expandHome(value) {
return String(value || '').replace(/^~(?=$|[\\/])/, os.homedir());
}
function parseArgs(argv) {
const first = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'install';
const args = {
command: first,
target: DEFAULT_TARGET,
smoke: false,
force: false,
help: false
};
if (first === 'workspace' || first === 'install-workspace') {
args.command = 'install';
args.target = WORKSPACE_TARGET;
}
for (let i = first === argv[0] ? 1 : 0; i < argv.length; i++) {
const token = argv[i];
if (token === '--target' && argv[i + 1]) {
args.target = argv[++i];
} else if (token.startsWith('--target=')) {
args.target = token.slice('--target='.length);
} else if (token === '--workspace') {
args.target = WORKSPACE_TARGET;
} else if (token === '--smoke') {
args.smoke = true;
} else if (token === '--force') {
args.force = true;
} else if (token === '--help' || token === '-h') {
args.help = true;
}
}
args.target = path.resolve(expandHome(args.target));
return args;
}
function printHelp() {
console.log([
'Claude measurement marker skill package installer',
'',
'Usage:',
' claude-measure-marker install [--target
] [--smoke]',
' claude-measure-marker workspace [--smoke]',
' claude-measure-marker install --workspace [--smoke]',
' claude-measure-marker check',
' claude-measure-marker smoke',
' claude-measure-marker path',
'',
'npx:',
' npx @gangvy/claude-code-measurement-marker-check install',
' npx @gangvy/claude-code-measurement-marker-check workspace --smoke',
'',
'Options:',
' --workspace Install into ./.claude/plugins/measurement-marker-check',
' --target Install into a custom directory',
' --force Allow overwriting an existing custom target',
' --smoke Run sample smoke checks after install',
' --help, -h Show help'
].join('\n'));
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function isInside(parentDir, childDir) {
const relative = path.relative(path.resolve(parentDir), path.resolve(childDir));
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
}
function isSafeDefaultTarget(targetDir) {
return path.resolve(targetDir) === path.resolve(DEFAULT_TARGET);
}
function isSafeWorkspaceTarget(targetDir) {
return isInside(WORKSPACE_PLUGINS_ROOT, targetDir);
}
function canOverwriteTarget(targetDir, force) {
return force || isSafeDefaultTarget(targetDir) || isSafeWorkspaceTarget(targetDir);
}
function copyDirRecursive(source, destination) {
const stat = fs.statSync(source);
if (stat.isDirectory()) {
ensureDir(destination);
for (const child of fs.readdirSync(source)) {
if (child === 'node_modules' || child === 'outputs' || child === '.git') continue;
copyDirRecursive(path.join(source, child), path.join(destination, child));
}
return;
}
ensureDir(path.dirname(destination));
fs.copyFileSync(source, destination);
}
function copyPlugin(target, force) {
if (fs.existsSync(target)) {
if (!canOverwriteTarget(target, force)) {
throw new Error(`Refusing to overwrite custom target without --force: ${target}`);
}
fs.rmSync(target, { recursive: true, force: true });
}
ensureDir(target);
for (const entry of COPY_ENTRIES) {
const source = path.join(SOURCE_ROOT, entry);
if (!fs.existsSync(source)) continue;
copyDirRecursive(source, path.join(target, entry));
}
}
function checkPlugin(target) {
const required = [
'.claude-plugin/plugin.json',
'skills/measurement-marker-check/SKILL.md',
'tools/measurement-marker-check-run.js',
'scripts/smoke.js',
'package.json'
];
const missing = required.filter(entry => !fs.existsSync(path.join(target, entry)));
if (missing.length) {
throw new Error(`Install target is missing required files: ${missing.join(', ')}`);
}
return {
status: 'ok',
target,
required
};
}
function asForwardSlash(filePath) {
return path.resolve(filePath).replace(/\\/g, '/');
}
function quotedPath(filePath) {
return `"${asForwardSlash(filePath)}"`;
}
function workspaceSkillText(target) {
const runner = path.join(target, 'tools', 'measurement-marker-check-run.js');
return [
'---',
'name: measurement-marker-check',
'description: Check 装修量尺图、手绘量尺单、户型草图或现场测量图片中是否存在疑似未标记位置,输出缺标记清单、待复核区域和下一步补标建议。Use when the user asks for 实战二、装修量尺、量尺图识别、尺寸图缺标记、哪里没标、未标注检查 or measurement drawing review.',
'allowed-tools: Read Write Bash(node *)',
'---',
'',
'# 装修量尺图缺标记检查官',
'',
'你是面向设计/量尺团队的量尺图缺标记检查官。用户要的是业务复核清单,不是技术日志。',
'',
'## 工作流',
'',
'1. 如果用户没有上传图片或给出图片路径,先请用户上传量尺图、手绘量尺单、户型草图或现场测量截图。',
'2. 如果用户上传了图片,先直接观察图片内容,整理已标记内容、疑似缺标位置、待人工复核区域。',
'3. 把观察结果写成结构化 JSON,保存到 `outputs/measurement-marker-check/input.json`。',
'4. 调用本地工具生成稳定报告。',
'5. 优先把工具返回的 `assistantMessage` 正文发给用户,文件路径只放末尾作为归档信息。',
'',
'## 检查口径',
'',
'- 空间名称:客厅、卧室、厨房、卫生间、阳台等。',
'- 关键尺寸:长、宽、高、门洞、窗洞、墙面、柜体位置等。',
'- 门窗洞口:宽高、离地高度、开启方向、门套或窗台限制。',
'- 梁柱/墙体限制:梁高、柱位、墙厚、承重/非承重提示。',
'- 水电/管道/烟道:下水、地漏、插座、开关、燃气、烟道、排水管。',
'- 安装避让备注:需避让、不可拆、需复核、现场限制、手写备注。',
'',
'## 工具输入 JSON 结构',
'',
'```json',
'{',
' "project": "项目或样本名称",',
' "image": "图片文件名或路径",',
' "detectedMarkers": [',
' { "area": "厨房右侧墙面", "markerType": "关键尺寸", "value": "3200mm", "evidence": "墙面横向尺寸已标注", "confidence": 0.9 }',
' ],',
' "missingMarkers": [',
' { "area": "窗户下沿", "missingType": "窗台高度", "risk": "可能影响台面、吊柜或窗帘盒设计", "evidence": "窗户位置已画出,但没有离地高度标注", "confidence": 0.78 }',
' ],',
' "uncertainAreas": [',
' { "area": "厨房右上角", "reason": "疑似烟道或立管,但文字不清晰", "action": "请现场照片或原始量尺单复核", "confidence": 0.58 }',
' ],',
' "overallJudgement": "存在关键缺标记,建议人工复核后再进入标准尺寸表。"',
'}',
'```',
'',
'## 生成报告命令',
'',
'```bash',
`node ${quotedPath(runner)} --input "outputs/measurement-marker-check/input.json" --output "outputs/measurement-marker-check" --result-prefix MEASURE_MARKER_RESULT`,
'```',
'',
'## 演示样例命令',
'',
'```bash',
`node ${quotedPath(runner)} --sample --output "outputs/measurement-marker-check-sample" --result-prefix MEASURE_MARKER_RESULT`,
'```',
'',
'## 输出规则',
'',
'- 先给结论:是否存在疑似缺标记。',
'- 必须明确“区域 + 缺失类型 + 风险 + 图像依据 + 置信度”。',
'- 看不清的地方归入“待人工复核区域”,不要强行判断。',
'- 不要把 P0 结果说成最终工程结论;它是辅助复核清单。',
'- 不要只返回文件路径。',
'',
'## 推荐启动话术',
'',
'```text',
'帮我检查这张装修量尺图有没有哪里未标记。',
'重点看空间名称、门窗洞口、长宽高、管道、插座/烟道和现场限制备注。',
'先给我缺标记清单和人工复核建议。',
'```',
''
].join('\n');
}
function writeWorkspaceSkillEntry(target) {
if (!isSafeWorkspaceTarget(target)) return;
const skillDir = path.join(WORKSPACE_ROOT, '.claude', 'skills', 'measurement-marker-check');
ensureDir(skillDir);
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), workspaceSkillText(target), 'utf8');
console.log('Wrote workspace skill entry: .\\.claude\\skills\\measurement-marker-check\\SKILL.md');
}
function runSmoke(target) {
const result = spawnSync(process.execPath, ['scripts/smoke.js'], {
cwd: target,
stdio: 'inherit',
shell: false
});
if (result.status !== 0) {
throw new Error('smoke failed');
}
}
function printNextSteps(target) {
const workspaceMode = isSafeWorkspaceTarget(target);
console.log('');
console.log('Install complete.');
console.log('');
if (workspaceMode) {
console.log('VSCode workspace mode is ready.');
console.log('');
console.log('Generated project files:');
console.log(' .\\.claude\\skills\\measurement-marker-check\\SKILL.md');
console.log(' .\\.claude\\plugins\\measurement-marker-check');
console.log('');
console.log('Restart the VSCode Claude Code session if it was already open.');
} else {
console.log('Claude Code CLI launch command:');
console.log(` claude --plugin-dir "${target}"`);
}
console.log('');
console.log('Try this prompt in Claude Code:');
console.log(' 帮我检查这张装修量尺图有没有哪里未标记。');
console.log(' 重点看空间名称、门窗洞口、长宽高、管道、插座/烟道和现场限制备注。');
console.log(' 先给我缺标记清单和人工复核建议。');
console.log('');
console.log(`Install target: ${target}`);
}
function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help || args.command === 'help') {
printHelp();
return;
}
if (args.command === 'path') {
console.log(args.target);
return;
}
if (args.command === 'install') {
copyPlugin(args.target, args.force);
const check = checkPlugin(args.target);
console.log(JSON.stringify(check, null, 2));
writeWorkspaceSkillEntry(args.target);
if (args.smoke) runSmoke(args.target);
printNextSteps(args.target);
return;
}
if (args.command === 'check') {
console.log(JSON.stringify(checkPlugin(args.target), null, 2));
return;
}
if (args.command === 'smoke') {
runSmoke(SOURCE_ROOT);
return;
}
printHelp();
process.exitCode = 1;
}
try {
main();
} catch (error) {
console.error(`claude-measure-marker failed: ${error.message}`);
process.exit(1);
}