Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,36 @@ npx heymark clean cursor claude-code # clean selected tool outputs
npx heymark help
```

### Dry-run Output Example

```text
$ npx heymark sync cursor codex --dry-run
[Sync]
repo: https://github.com/MosslandOpenDevs/heymark.git

mode: dry-run (no files will be written or removed)
[Clean]
Removed: .cursor/rules
Removed: .agents/skills

Cursor -> .cursor/rules/*.mdc (7 skills)
Codex -> .agents/skills/*/SKILL.md (7 skills)

[Dry-run Summary]
Cursor create: 1 update: 2 delete: 1
+ .cursor/rules/new-skill.mdc
~ .cursor/rules/code-conventions.mdc
~ .cursor/rules/readme-writing.mdc
- .cursor/rules/legacy-skill.mdc
Codex create: 2 update: 0 delete: 1
+ .agents/skills/new-skill/SKILL.md
+ .agents/skills/ai-behavior/SKILL.md
- .agents/skills/legacy-skill/SKILL.md
TOTAL create: 3 update: 2 delete: 2

[Done] 2 tools synced (dry-run).
```

## How to Dev

### Tech Stack
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
"type": "commonjs",
"main": "src/index.js",
"bin": "src/index.js",
"scripts": {
"test": "node --test"
},
"keywords": [
"ai-coding-assistant",
"ai-agent",
Expand Down
48 changes: 48 additions & 0 deletions src/commands/sync/dry-run-summary.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
function buildDryRunSummary(tools, selectedTools, skills, cwd) {
const perTool = [];
const totals = { created: 0, updated: 0, deleted: 0 };

for (const toolKey of selectedTools) {
const tool = tools[toolKey];
const preview = typeof tool.preview === "function" ? tool.preview(skills, cwd) : {};
const created = Array.isArray(preview.created) ? preview.created.slice().sort() : [];
const updated = Array.isArray(preview.updated) ? preview.updated.slice().sort() : [];
const deleted = Array.isArray(preview.deleted) ? preview.deleted.slice().sort() : [];

perTool.push({
toolName: tool.name,
created,
updated,
deleted,
});

totals.created += created.length;
totals.updated += updated.length;
totals.deleted += deleted.length;
}

return { perTool, totals };
}

function printDryRunSummary(summary) {
console.log("");
console.log("[Dry-run Summary]");

for (const row of summary.perTool) {
console.log(
` ${row.toolName.padEnd(16)} create:${String(row.created.length).padStart(3)} update:${String(row.updated.length).padStart(3)} delete:${String(row.deleted.length).padStart(3)}`
);
row.created.forEach((p) => console.log(` + ${p}`));
row.updated.forEach((p) => console.log(` ~ ${p}`));
row.deleted.forEach((p) => console.log(` - ${p}`));
}

console.log(
` ${"TOTAL".padEnd(16)} create:${String(summary.totals.created).padStart(3)} update:${String(summary.totals.updated).padStart(3)} delete:${String(summary.totals.deleted).padStart(3)}`
);
}

module.exports = {
buildDryRunSummary,
printDryRunSummary,
};
32 changes: 30 additions & 2 deletions src/commands/sync/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,26 @@ const { selectTools } = require("@/commands/select-tools");
const { readCache } = require("@/skill-repo/cache-folder");
const { readConfig } = require("@/skill-repo/config-file");
const { SKILL_REPO_DEFAULT_BRANCH } = require("@/skill-repo/constants");
const { buildDryRunSummary, printDryRunSummary } = require("@/commands/sync/dry-run-summary");

function extractDryRun(flags) {
const dryRunFlags = new Set(["--dry-run", "-n"]);
const dryRun = flags.some((flag) => dryRunFlags.has(flag));
const positional = flags.filter((flag) => !dryRunFlags.has(flag));
return { dryRun, positional };
}

function runSync(flags, context) {
const selectedTools = selectTools(flags, context.tools);
const { dryRun, positional } = extractDryRun(flags);
const selectedTools = selectTools(positional, context.tools);
const { skills } = readCache(context.cwd);
const config = readConfig(context.cwd);

const previousDryRun = process.env.HEYMARK_DRY_RUN;
if (dryRun) {
process.env.HEYMARK_DRY_RUN = "1";
}

console.log("[Sync]");
if (config) {
console.log(` repo: ${config.repoUrl}`);
Expand All @@ -17,7 +31,12 @@ function runSync(flags, context) {
}
console.log("");

if (dryRun) {
console.log(" mode: dry-run (no files will be written or removed)");
}

const skillNames = skills.map((s) => s.name);
const dryRunSummary = dryRun ? buildDryRunSummary(context.tools, selectedTools, skills, context.cwd) : null;
cleaner(context.tools, selectedTools, skillNames, context.cwd);

for (const toolKey of selectedTools) {
Expand All @@ -26,8 +45,17 @@ function runSync(flags, context) {
console.log(` ${tool.name.padEnd(16)} -> ${tool.output} (${count} skills)`);
}

if (dryRun && dryRunSummary) {
printDryRunSummary(dryRunSummary);
}

if (dryRun) {
if (previousDryRun === undefined) delete process.env.HEYMARK_DRY_RUN;
else process.env.HEYMARK_DRY_RUN = previousDryRun;
}

console.log("");
console.log(`[Done] ${selectedTools.length} tools synced.`);
console.log(`[Done] ${selectedTools.length} tools synced${dryRun ? " (dry-run)" : ""}.`);
}

module.exports = {
Expand Down
12 changes: 11 additions & 1 deletion src/tools/antigravity/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { ANTIGRAVITY } = require("@/tools/constants");
const { generate, clean } = require("@/tools/skill-per-folder");
const { generate, preview, clean } = require("@/tools/skill-per-folder");

function createContent(skill) {
const frontmatterLines = [
Expand Down Expand Up @@ -27,6 +27,16 @@ module.exports = {
});
},

preview(skills, cwd) {
return preview({
cwd,
dir: ANTIGRAVITY.SKILLS_DIR,
fileName: ANTIGRAVITY.SKILL_FILE_NAME,
skills,
createContent,
});
},

clean(skillNames, cwd) {
return clean(cwd, ANTIGRAVITY.SKILLS_DIR);
},
Expand Down
12 changes: 11 additions & 1 deletion src/tools/claude-code/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { CLAUDE_CODE } = require("@/tools/constants");
const { generate, clean } = require("@/tools/skill-per-folder");
const { generate, preview, clean } = require("@/tools/skill-per-folder");

function createContent(skill) {
const frontmatterLines = [
Expand Down Expand Up @@ -27,6 +27,16 @@ module.exports = {
});
},

preview(skills, cwd) {
return preview({
cwd,
dir: CLAUDE_CODE.SKILLS_DIR,
fileName: CLAUDE_CODE.SKILL_FILE_NAME,
skills,
createContent,
});
},

clean(skillNames, cwd) {
return clean(cwd, CLAUDE_CODE.SKILLS_DIR);
},
Expand Down
12 changes: 11 additions & 1 deletion src/tools/codex/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { CODEX } = require("@/tools/constants");
const { generate, clean } = require("@/tools/skill-per-folder");
const { generate, preview, clean } = require("@/tools/skill-per-folder");

function createContent(skill) {
const frontmatterLines = [
Expand Down Expand Up @@ -27,6 +27,16 @@ module.exports = {
});
},

preview(skills, cwd) {
return preview({
cwd,
dir: CODEX.SKILLS_DIR,
fileName: CODEX.SKILL_FILE_NAME,
skills,
createContent,
});
},

clean(skillNames, cwd) {
return clean(cwd, CODEX.SKILLS_DIR);
},
Expand Down
13 changes: 12 additions & 1 deletion src/tools/copilot/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { COPILOT } = require("@/tools/constants");
const { generate, clean } = require("@/tools/skill-per-file");
const { generate, preview, clean } = require("@/tools/skill-per-file");

function getFileName(skill) {
return `${skill.name}${COPILOT.FILE_SUFFIX}`;
Expand Down Expand Up @@ -34,6 +34,17 @@ module.exports = {
});
},

preview(skills, cwd) {
return preview({
cwd,
dir: COPILOT.INSTRUCTIONS_DIR,
skills,
getFileName,
createContent,
fileSuffix: COPILOT.FILE_SUFFIX,
});
},

clean(skillNames, cwd) {
return clean(cwd, COPILOT.INSTRUCTIONS_DIR);
},
Expand Down
13 changes: 12 additions & 1 deletion src/tools/cursor/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { CURSOR } = require("@/tools/constants");
const { generate, clean } = require("@/tools/skill-per-file");
const { generate, preview, clean } = require("@/tools/skill-per-file");

function getFileName(skill) {
return `${skill.name}${CURSOR.FILE_SUFFIX}`;
Expand Down Expand Up @@ -33,6 +33,17 @@ module.exports = {
});
},

preview(skills, cwd) {
return preview({
cwd,
dir: CURSOR.SKILLS_DIR,
skills,
getFileName,
createContent,
fileSuffix: CURSOR.FILE_SUFFIX,
});
},

clean(skillNames, cwd) {
return clean(cwd, CURSOR.SKILLS_DIR);
},
Expand Down
46 changes: 46 additions & 0 deletions src/tools/skill-per-file.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,51 @@ function generate({ cwd, dir, skills, getFileName, createContent }) {
return skills.length;
}

function preview({ cwd, dir, skills, getFileName, createContent, fileSuffix }) {
const destDir = path.join(cwd, dir);
const expected = new Map();
const created = [];
const updated = [];
const deleted = [];

for (const skill of skills) {
const relativePath = path.join(dir, getFileName(skill));
expected.set(relativePath, createContent(skill));
}

for (const [relativePath, content] of expected.entries()) {
const absolutePath = path.join(cwd, relativePath);
if (!fs.existsSync(absolutePath)) {
created.push(relativePath);
continue;
}

const current = fs.readFileSync(absolutePath, "utf8");
if (current !== content) {
updated.push(relativePath);
}
}

if (fs.existsSync(destDir)) {
const existing = fs
.readdirSync(destDir)
.filter((fileName) => (fileSuffix ? fileName.endsWith(fileSuffix) : true))
.map((fileName) => path.join(dir, fileName));

for (const relativePath of existing) {
if (!expected.has(relativePath)) {
deleted.push(relativePath);
}
}
}

return {
created: created.sort(),
updated: updated.sort(),
deleted: deleted.sort(),
};
}

function clean(cwd, dir) {
const targetPath = path.join(cwd, dir);
if (!fs.existsSync(targetPath)) {
Expand All @@ -25,5 +70,6 @@ function clean(cwd, dir) {

module.exports = {
generate,
preview,
clean,
};
46 changes: 46 additions & 0 deletions src/tools/skill-per-folder.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,53 @@ function generate({ cwd, dir, fileName, skills, createContent }) {
return skills.length;
}

function preview({ cwd, dir, fileName, skills, createContent }) {
const baseDir = path.join(cwd, dir);
const expected = new Map();
const created = [];
const updated = [];
const deleted = [];

for (const skill of skills) {
const relativePath = path.join(dir, skill.name, fileName);
expected.set(relativePath, createContent(skill));
}

for (const [relativePath, content] of expected.entries()) {
const absolutePath = path.join(cwd, relativePath);
if (!fs.existsSync(absolutePath)) {
created.push(relativePath);
continue;
}

const current = fs.readFileSync(absolutePath, "utf8");
if (current !== content) {
updated.push(relativePath);
}
}

if (fs.existsSync(baseDir)) {
const existing = fs
.readdirSync(baseDir)
.filter((name) => fs.statSync(path.join(baseDir, name)).isDirectory())
.map((name) => path.join(dir, name, fileName));

for (const relativePath of existing) {
if (!expected.has(relativePath)) {
deleted.push(relativePath);
}
}
}

return {
created: created.sort(),
updated: updated.sort(),
deleted: deleted.sort(),
};
}

module.exports = {
generate,
preview,
clean,
};
Loading