agents-sync 0.1.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.
package/SCHEMA.MD ADDED
@@ -0,0 +1,156 @@
1
+ # Canonical Agents Schema (Markdown)
2
+
3
+ This repo treats `.agents` as the source of truth. Each file is Markdown with an optional metadata block at the top. The watcher parses these files and generates tool-specific outputs for Claude, Gemini, and Codex.
4
+
5
+ ## Folder Layout
6
+
7
+ `.agents/commands/` holds command definitions.
8
+ `.agents/hooks/` holds hook definitions.
9
+ `.agents/skills/` holds skill definitions.
10
+ `AGENTS.MD` is the global config document.
11
+
12
+ ## Canonical File Format
13
+
14
+ Each file is Markdown. At the top you can include a metadata fence:
15
+
16
+ ```agents
17
+ id: hi
18
+ type: command
19
+ title: Say Hello
20
+ description: Greet the user politely.
21
+ aliases: hello, greet
22
+ ```
23
+
24
+ Everything after the `agents` fence is the body. The body becomes the tool-specific prompt or instructions.
25
+
26
+ ### Required Fields
27
+
28
+ `id` or `name` must be present, or it will be inferred from the filename.
29
+ `type` can be omitted if the folder implies it (`commands`, `hooks`, `skills`).
30
+
31
+ ### Optional Fields
32
+
33
+ `title` short label for humans.
34
+ `description` short summary.
35
+ `aliases` comma-separated list of alternate names.
36
+ `event` for hooks (example: `event: pre-commit`).
37
+ `tags` and `tools` are allowed but currently not emitted to targets.
38
+
39
+ ### List Fields
40
+
41
+ For list fields (`aliases`, `tags`, `tools`) you can use:
42
+
43
+ `aliases: hello, greet` or `aliases: ["hello", "greet"]`.
44
+
45
+ ## AGENTS.MD Schema
46
+
47
+ `AGENTS.MD` is the canonical global configuration document. It uses the same metadata fence and body rules.
48
+
49
+ Recommended template:
50
+
51
+ ```agents
52
+ id: workspace
53
+ type: config
54
+ title: Workspace Defaults
55
+ description: Global guidance applied across tools.
56
+ ```
57
+
58
+ Use the body for shared rules, style, and constraints that should apply everywhere.
59
+
60
+ ## Canonical Examples
61
+
62
+ ### Command
63
+
64
+ ```agents
65
+ id: hi
66
+ type: command
67
+ title: Say Hello
68
+ description: Greet the user politely.
69
+ aliases: hello, greet
70
+ ```
71
+
72
+ Write a friendly hello and ask how the user wants to proceed.
73
+
74
+ ### Hook
75
+
76
+ ```agents
77
+ id: before_commit
78
+ type: hook
79
+ title: Pre-Commit Guard
80
+ event: pre-commit
81
+ ```
82
+
83
+ Check for unstaged files and remind the user to run tests.
84
+
85
+ ### Skill
86
+
87
+ ```agents
88
+ id: bug_triage
89
+ type: skill
90
+ title: Bug Triage
91
+ description: Quick triage checklist.
92
+ ```
93
+
94
+ Classify severity, confirm reproduction steps, and identify owners.
95
+
96
+ ### Config (Root)
97
+
98
+ `AGENTS.MD` is the shared config document.
99
+
100
+ ```agents
101
+ type: config
102
+ title: Workspace Defaults
103
+ ```
104
+
105
+ This is the global guidance for the workspace, shared across tools.
106
+
107
+ ## Translation Rules
108
+
109
+ The translation layer renders tool-specific files using the canonical metadata and body.
110
+
111
+ ### Claude
112
+
113
+ Output format: Markdown.
114
+ Output paths:
115
+ `.claude/commands/<id>.md`
116
+ `.claude/hooks/<id>.md`
117
+ `.claude/skills/<id>.md`
118
+ `CLAUDE.MD` from `AGENTS.MD`.
119
+
120
+ Mapping:
121
+ `id` becomes the command name.
122
+ `description`, `title`, `aliases`, and `event` are emitted as YAML frontmatter.
123
+ Body is emitted as the command or hook prompt.
124
+
125
+ ### Gemini
126
+
127
+ Output format: TOML.
128
+ Output paths:
129
+ `.gemini/commands/<id>.toml`
130
+ `.gemini/hooks/<id>.toml`
131
+ `.gemini/skills/<id>.toml`
132
+ `GEMINI.MD` from `AGENTS.MD`.
133
+
134
+ Mapping:
135
+ `type`, `name`, `title`, `description`, `aliases`, and `event` become TOML keys.
136
+ Body becomes `prompt = """..."""`.
137
+
138
+ ### Codex
139
+
140
+ Output format: Markdown.
141
+ Output paths:
142
+ `.codex/commands/<id>.md`
143
+ `.codex/hooks/<id>.md`
144
+ `.codex/skills/<id>.md`
145
+ `CODEX.MD` from `AGENTS.MD`.
146
+
147
+ Mapping:
148
+ Top heading `# <type>: <id>`.
149
+ `title`, `description`, `aliases`, and `event` emitted as labeled lines.
150
+ Body emitted as the prompt.
151
+
152
+ ## Notes
153
+
154
+ The metadata fence must be the first block in the file to be parsed.
155
+ Everything after the metadata fence is treated as the body.
156
+ If metadata is missing, `type` is inferred from the folder and `id` from the filename.
@@ -0,0 +1,22 @@
1
+ {
2
+ "sourceDir": ".agents",
3
+ "agentsSubdir": "",
4
+ "targets": {
5
+ "claude": {
6
+ "outputDir": ".claude"
7
+ },
8
+ "codex": {
9
+ "outputDir": ".codex"
10
+ },
11
+ "gemini": {
12
+ "outputDir": ".gemini",
13
+ "agentFileExtension": ".toml"
14
+ }
15
+ },
16
+ "rootDoc": "AGENTS.MD",
17
+ "rootTargets": {
18
+ "claude": "CLAUDE.MD",
19
+ "gemini": "GEMINI.MD",
20
+ "codex": "CODEX.MD"
21
+ }
22
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "./index.js";
package/dist/config.js ADDED
@@ -0,0 +1,21 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { existsSync } from "node:fs";
4
+ const DEFAULT_CONFIG = {
5
+ sourceDir: ".agents",
6
+ agentsSubdir: "",
7
+ targets: {},
8
+ rootDoc: "AGENTS.MD",
9
+ rootTargets: {}
10
+ };
11
+ export const loadConfig = async (configPath) => {
12
+ if (!existsSync(configPath))
13
+ return DEFAULT_CONFIG;
14
+ const raw = await fs.readFile(configPath, "utf8");
15
+ const parsed = JSON.parse(raw);
16
+ return { ...DEFAULT_CONFIG, ...parsed };
17
+ };
18
+ export const resolveTargetAgentsDir = (cwd, target, agentsSubdir) => {
19
+ const base = target.agentsOutputDir ?? path.join(target.outputDir, agentsSubdir);
20
+ return path.resolve(cwd, base);
21
+ };
package/dist/index.js ADDED
@@ -0,0 +1,132 @@
1
+ import chokidar from "chokidar";
2
+ import path from "node:path";
3
+ import fs from "node:fs/promises";
4
+ import { existsSync } from "node:fs";
5
+ import { loadConfig, resolveTargetAgentsDir } from "./config.js";
6
+ import { parseAgentsMarkdown, inferKindFromPath } from "./schema.js";
7
+ import { translators } from "./translation/index.js";
8
+ const CWD = process.cwd();
9
+ const CONFIG_PATH = path.join(CWD, "agents.config.json");
10
+ const ensureDir = async (dirPath) => {
11
+ await fs.mkdir(dirPath, { recursive: true });
12
+ };
13
+ const writeIfChanged = async (filePath, content) => {
14
+ let existing = null;
15
+ try {
16
+ existing = await fs.readFile(filePath, "utf8");
17
+ }
18
+ catch {
19
+ existing = null;
20
+ }
21
+ if (existing === content)
22
+ return;
23
+ await ensureDir(path.dirname(filePath));
24
+ await fs.writeFile(filePath, content, "utf8");
25
+ };
26
+ const walkFiles = async (dirPath) => {
27
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
28
+ const files = [];
29
+ for (const entry of entries) {
30
+ const full = path.join(dirPath, entry.name);
31
+ if (entry.isDirectory()) {
32
+ files.push(...(await walkFiles(full)));
33
+ }
34
+ else if (entry.isFile()) {
35
+ files.push(full);
36
+ }
37
+ }
38
+ return files;
39
+ };
40
+ const main = async () => {
41
+ const config = await loadConfig(CONFIG_PATH);
42
+ const sourceDir = path.resolve(CWD, config.sourceDir);
43
+ const rootDoc = path.resolve(CWD, config.rootDoc);
44
+ const targetEntries = Object.entries(config.targets);
45
+ for (const [, target] of targetEntries) {
46
+ await ensureDir(resolveTargetAgentsDir(CWD, target, config.agentsSubdir));
47
+ }
48
+ const syncSourceFile = async (filePath) => {
49
+ const rel = path.relative(sourceDir, filePath);
50
+ const kind = inferKindFromPath(rel);
51
+ if (!kind)
52
+ return;
53
+ const id = path.basename(rel, path.extname(rel));
54
+ const content = await fs.readFile(filePath, "utf8");
55
+ const doc = parseAgentsMarkdown(content, { kind, id });
56
+ await Promise.all(targetEntries.map(async ([targetKey, target]) => {
57
+ const translator = translators[targetKey];
58
+ if (!translator)
59
+ return;
60
+ const baseDir = resolveTargetAgentsDir(CWD, target, config.agentsSubdir);
61
+ const subdir = translator.subdir(doc, target);
62
+ const ext = translator.fileExtension(doc, target);
63
+ const outPath = path.join(baseDir, subdir, `${doc.id}${ext}`);
64
+ const translated = translator.renderDoc(doc, target);
65
+ await writeIfChanged(outPath, translated);
66
+ }));
67
+ };
68
+ const removeSourceFile = async (filePath) => {
69
+ const rel = path.relative(sourceDir, filePath);
70
+ const kind = inferKindFromPath(rel);
71
+ if (!kind)
72
+ return;
73
+ const id = path.basename(rel, path.extname(rel));
74
+ const doc = parseAgentsMarkdown("", { kind, id });
75
+ await Promise.all(targetEntries.map(async ([targetKey, target]) => {
76
+ const translator = translators[targetKey];
77
+ if (!translator)
78
+ return;
79
+ const baseDir = resolveTargetAgentsDir(CWD, target, config.agentsSubdir);
80
+ const subdir = translator.subdir(doc, target);
81
+ const ext = translator.fileExtension(doc, target);
82
+ const outPath = path.join(baseDir, subdir, `${id}${ext}`);
83
+ await fs.rm(outPath, { force: true });
84
+ }));
85
+ };
86
+ const syncRootDoc = async () => {
87
+ if (!existsSync(rootDoc))
88
+ return;
89
+ const content = await fs.readFile(rootDoc, "utf8");
90
+ const doc = parseAgentsMarkdown(content, { kind: "config", id: "root" });
91
+ const rootTargets = config.rootTargets;
92
+ await Promise.all(Object.entries(rootTargets).map(async ([targetKey, outName]) => {
93
+ const translator = translators[targetKey];
94
+ if (!translator)
95
+ return;
96
+ const outPath = path.resolve(CWD, outName);
97
+ const translated = translator.renderDoc(doc, config.targets[targetKey]);
98
+ await writeIfChanged(outPath, translated);
99
+ }));
100
+ };
101
+ if (existsSync(sourceDir)) {
102
+ const files = await walkFiles(sourceDir);
103
+ for (const filePath of files) {
104
+ await syncSourceFile(filePath);
105
+ }
106
+ }
107
+ await syncRootDoc();
108
+ const agentsWatcher = chokidar.watch(sourceDir, {
109
+ ignoreInitial: true,
110
+ awaitWriteFinish: {
111
+ stabilityThreshold: 150,
112
+ pollInterval: 50
113
+ }
114
+ });
115
+ agentsWatcher.on("add", syncSourceFile);
116
+ agentsWatcher.on("change", syncSourceFile);
117
+ agentsWatcher.on("unlink", removeSourceFile);
118
+ const rootWatcher = chokidar.watch(rootDoc, {
119
+ ignoreInitial: true,
120
+ awaitWriteFinish: {
121
+ stabilityThreshold: 150,
122
+ pollInterval: 50
123
+ }
124
+ });
125
+ rootWatcher.on("add", syncRootDoc);
126
+ rootWatcher.on("change", syncRootDoc);
127
+ process.stdout.write("agents-sync watching .agents and AGENTS.MD\n");
128
+ };
129
+ main().catch((err) => {
130
+ console.error(err);
131
+ process.exit(1);
132
+ });
package/dist/schema.js ADDED
@@ -0,0 +1,83 @@
1
+ import path from "node:path";
2
+ const LIST_FIELDS = new Set(["aliases", "tags", "tools"]);
3
+ const parseList = (raw) => {
4
+ const trimmed = raw.trim();
5
+ if (!trimmed)
6
+ return [];
7
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
8
+ try {
9
+ const parsed = JSON.parse(trimmed);
10
+ if (Array.isArray(parsed))
11
+ return parsed.map(String);
12
+ }
13
+ catch {
14
+ // fall through to comma parsing
15
+ }
16
+ }
17
+ return trimmed
18
+ .split(",")
19
+ .map((v) => v.trim())
20
+ .filter(Boolean);
21
+ };
22
+ const parseMetaBlock = (raw) => {
23
+ const meta = {};
24
+ const lines = raw.split(/\r?\n/);
25
+ for (const line of lines) {
26
+ const trimmed = line.trim();
27
+ if (!trimmed || trimmed.startsWith("#"))
28
+ continue;
29
+ const idx = trimmed.indexOf(":");
30
+ if (idx === -1)
31
+ continue;
32
+ const key = trimmed.slice(0, idx).trim();
33
+ const value = trimmed.slice(idx + 1).trim();
34
+ if (!key)
35
+ continue;
36
+ if (LIST_FIELDS.has(key)) {
37
+ meta[key] = parseList(value);
38
+ }
39
+ else {
40
+ meta[key] = value;
41
+ }
42
+ }
43
+ return meta;
44
+ };
45
+ export const parseAgentsMarkdown = (content, fallback) => {
46
+ const fenceMatch = content.match(/^```agents\s*\n([\s\S]*?)\n```\s*\n?/i);
47
+ const meta = fenceMatch ? parseMetaBlock(fenceMatch[1]) : {};
48
+ const body = fenceMatch
49
+ ? content.slice(fenceMatch[0].length).trim()
50
+ : content.trim();
51
+ const kind = meta.type ||
52
+ meta.kind ||
53
+ fallback.kind;
54
+ const id = meta.id ||
55
+ meta.name ||
56
+ fallback.id;
57
+ const title = meta.title || undefined;
58
+ const description = meta.description || undefined;
59
+ const aliases = meta.aliases || undefined;
60
+ const event = meta.event || undefined;
61
+ return {
62
+ kind,
63
+ id,
64
+ title,
65
+ description,
66
+ aliases,
67
+ event,
68
+ body,
69
+ meta
70
+ };
71
+ };
72
+ export const inferKindFromPath = (relPath) => {
73
+ const parts = relPath.split(path.sep).map((p) => p.toLowerCase());
74
+ if (parts.includes("commands"))
75
+ return "command";
76
+ if (parts.includes("hooks"))
77
+ return "hook";
78
+ if (parts.includes("skills"))
79
+ return "skill";
80
+ if (parts.includes("config") || parts.includes("configs"))
81
+ return "config";
82
+ return null;
83
+ };
@@ -0,0 +1,43 @@
1
+ /** Docs
2
+ * @see https://platform.claude.com/docs/en/home
3
+ */
4
+ const yamlList = (label, items) => {
5
+ if (!items.length)
6
+ return [];
7
+ const lines = [label + ":"];
8
+ for (const item of items)
9
+ lines.push(` - ${item}`);
10
+ return lines;
11
+ };
12
+ const renderFrontmatter = (doc) => {
13
+ const lines = ["---", `name: ${doc.id}`];
14
+ if (doc.title)
15
+ lines.push(`title: ${doc.title}`);
16
+ if (doc.description)
17
+ lines.push(`description: ${doc.description}`);
18
+ if (doc.kind === "hook" && doc.event)
19
+ lines.push(`event: ${doc.event}`);
20
+ if (doc.aliases?.length)
21
+ lines.push(...yamlList("aliases", doc.aliases));
22
+ lines.push("---");
23
+ return lines.join("\n");
24
+ };
25
+ export const renderDoc = (doc) => {
26
+ const parts = [renderFrontmatter(doc)];
27
+ if (doc.body)
28
+ parts.push(doc.body);
29
+ return parts.join("\n\n");
30
+ };
31
+ export const fileExtension = (_doc) => ".md";
32
+ export const subdir = (doc) => {
33
+ switch (doc.kind) {
34
+ case "command":
35
+ return "commands";
36
+ case "hook":
37
+ return "hooks";
38
+ case "skill":
39
+ return "skills";
40
+ default:
41
+ return "";
42
+ }
43
+ };
@@ -0,0 +1,47 @@
1
+ /** Docs
2
+ * @see https://developers.openai.com/codex/
3
+ */
4
+ const sectionLine = (label, value) => {
5
+ if (!value)
6
+ return null;
7
+ if (Array.isArray(value)) {
8
+ if (!value.length)
9
+ return null;
10
+ return `${label}: ${value.join(", ")}`;
11
+ }
12
+ return `${label}: ${value}`;
13
+ };
14
+ export const renderDoc = (doc) => {
15
+ const lines = [];
16
+ lines.push(`# ${doc.kind}: ${doc.id}`);
17
+ const titleLine = sectionLine("Title", doc.title);
18
+ if (titleLine)
19
+ lines.push(titleLine);
20
+ const descLine = sectionLine("Description", doc.description);
21
+ if (descLine)
22
+ lines.push(descLine);
23
+ const aliasLine = sectionLine("Aliases", doc.aliases);
24
+ if (aliasLine)
25
+ lines.push(aliasLine);
26
+ if (doc.kind === "hook" && doc.event) {
27
+ lines.push(`Event: ${doc.event}`);
28
+ }
29
+ if (doc.body) {
30
+ lines.push("");
31
+ lines.push(doc.body);
32
+ }
33
+ return lines.join("\n");
34
+ };
35
+ export const fileExtension = (_doc) => ".md";
36
+ export const subdir = (doc) => {
37
+ switch (doc.kind) {
38
+ case "command":
39
+ return "commands";
40
+ case "hook":
41
+ return "hooks";
42
+ case "skill":
43
+ return "skills";
44
+ default:
45
+ return "";
46
+ }
47
+ };
@@ -0,0 +1,50 @@
1
+ /** Docs
2
+ * @see https://geminicli.com/docs/
3
+ */
4
+ const tomlString = (value) => {
5
+ const escaped = value.replace(/"""/g, "\\\"\\\"\\\"");
6
+ return `"""${escaped}"""`;
7
+ };
8
+ const tomlLine = (key, value) => {
9
+ if (!value)
10
+ return null;
11
+ return `${key} = ${JSON.stringify(value)}`;
12
+ };
13
+ const tomlArray = (key, items) => {
14
+ if (!items?.length)
15
+ return null;
16
+ return `${key} = [${items.map((i) => JSON.stringify(i)).join(", ")}]`;
17
+ };
18
+ export const renderDoc = (doc) => {
19
+ const lines = [];
20
+ lines.push(`type = ${JSON.stringify(doc.kind)}`);
21
+ lines.push(`name = ${JSON.stringify(doc.id)}`);
22
+ const titleLine = tomlLine("title", doc.title);
23
+ if (titleLine)
24
+ lines.push(titleLine);
25
+ const descLine = tomlLine("description", doc.description);
26
+ if (descLine)
27
+ lines.push(descLine);
28
+ const aliasLine = tomlArray("aliases", doc.aliases);
29
+ if (aliasLine)
30
+ lines.push(aliasLine);
31
+ if (doc.kind === "hook" && doc.event) {
32
+ lines.push(`event = ${JSON.stringify(doc.event)}`);
33
+ }
34
+ const body = doc.body ?? "";
35
+ lines.push(`prompt = ${tomlString(body)}`);
36
+ return lines.join("\n");
37
+ };
38
+ export const fileExtension = (_doc) => ".toml";
39
+ export const subdir = (doc) => {
40
+ switch (doc.kind) {
41
+ case "command":
42
+ return "commands";
43
+ case "hook":
44
+ return "hooks";
45
+ case "skill":
46
+ return "skills";
47
+ default:
48
+ return "";
49
+ }
50
+ };
@@ -0,0 +1,13 @@
1
+ import * as claude from "./claude.js";
2
+ import * as gemini from "./gemini.js";
3
+ import * as codex from "./codex.js";
4
+ const withOverrides = (base) => ({
5
+ renderDoc: (doc, _target) => base.renderDoc(doc),
6
+ fileExtension: (doc, target) => target?.agentFileExtension ?? base.fileExtension(doc),
7
+ subdir: (doc, target) => base.subdir(doc, target)
8
+ });
9
+ export const translators = {
10
+ claude: withOverrides(claude),
11
+ gemini: withOverrides(gemini),
12
+ codex: withOverrides(codex)
13
+ };
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "agents-sync",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "agents-sync": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "SCHEMA.MD",
12
+ "agents.config.json"
13
+ ],
14
+ "scripts": {
15
+ "dev": "tsx src/index.ts",
16
+ "build": "tsc -p tsconfig.json",
17
+ "postbuild": "node scripts/add-shebang.mjs",
18
+ "start": "node dist/index.js",
19
+ "prepare": "npm run build"
20
+ },
21
+ "dependencies": {
22
+ "chokidar": "^3.6.0"
23
+ },
24
+ "devDependencies": {
25
+ "tsx": "^4.7.0",
26
+ "typescript": "^5.4.0"
27
+ }
28
+ }
package/readme.md ADDED
@@ -0,0 +1,46 @@
1
+ # agents-sync
2
+
3
+ A small TypeScript watcher that keeps `.agents` as the shared source of truth and mirrors translated files into `.claude`, `.codex`, and `.gemini`. It also mirrors `AGENTS.MD` into `CLAUDE.MD`, `GEMINI.MD`, and `CODEX.MD` using the same translation rules.
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ npm install
9
+ npm run dev
10
+ ```
11
+
12
+ ## Using As A CLI
13
+
14
+ After `npm install` you can run:
15
+
16
+ ```bash
17
+ npm run build
18
+ npm link
19
+ agents-sync
20
+ ```
21
+
22
+ To use in another project:
23
+
24
+ ```bash
25
+ npm i -D agents-sync
26
+ npx agents-sync
27
+ ```
28
+
29
+ ## Canonical Schema
30
+
31
+ The canonical format is Markdown with an optional `agents` metadata fence. See `SCHEMA.MD` for the full schema and translation rules.
32
+
33
+ ## Configuration
34
+
35
+ Edit `agents.config.json` to change output folders or extensions.
36
+
37
+ - `sourceDir`: shared canonical folder.
38
+ - `agentsSubdir`: optional subdir within each target output (empty by default).
39
+ - `targets`: output directories and file extensions per tool.
40
+ - `rootDoc`: shared canonical root doc.
41
+ - `rootTargets`: which target docs to emit.
42
+
43
+ ## Notes
44
+
45
+ - Output files are only written when content changes.
46
+ - Unknown folders under `.agents` are ignored unless they are `commands`, `hooks`, or `skills`.