@shruubi/agentconfig 0.2.0 → 0.3.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/README.md CHANGED
@@ -47,6 +47,15 @@ agentconfig sync
47
47
 
48
48
  Default source root is `~/.agentconfig`. Override with `AGENTCONFIG_HOME`.
49
49
 
50
+ ## Default mappings
51
+
52
+ The default template includes common locations for agent instructions, rules, skills, and (where supported) agents, commands, and hooks.
53
+
54
+ - Claude Code hooks are synced via `claude/settings.json`, plus agents and commands via `claude/agents/` and `claude/commands/`.
55
+ - Codex skills are synced to `.agents/skills/` (global uses `${CODEX_HOME:-~/.codex}` root with a relative `../.agents/skills/`).
56
+ - Cursor hooks are synced via `cursor/hooks.json` and `cursor/hooks/`.
57
+ - OpenCode agents and commands are synced via `agents/` and `commands/`.
58
+
50
59
  ## Tests
51
60
 
52
61
  ```bash
package/dist/config.js CHANGED
@@ -18,6 +18,90 @@ function getConfigPath(root) {
18
18
  function isErrnoException(error) {
19
19
  return typeof error === "object" && error !== null && "code" in error;
20
20
  }
21
+ function isRecord(value) {
22
+ return typeof value === "object" && value !== null;
23
+ }
24
+ function isMappingFile(value) {
25
+ if (!isRecord(value)) {
26
+ return false;
27
+ }
28
+ return typeof value.source === "string" && typeof value.target === "string";
29
+ }
30
+ function isAgentScopeConfig(value) {
31
+ if (!isRecord(value)) {
32
+ return false;
33
+ }
34
+ if (typeof value.root !== "string") {
35
+ return false;
36
+ }
37
+ if (!Array.isArray(value.files)) {
38
+ return false;
39
+ }
40
+ return value.files.every((entry) => isMappingFile(entry));
41
+ }
42
+ function isAgentConfigEntry(value) {
43
+ if (!isRecord(value)) {
44
+ return false;
45
+ }
46
+ if (typeof value.displayName !== "string") {
47
+ return false;
48
+ }
49
+ if (value.global !== undefined && !isAgentScopeConfig(value.global)) {
50
+ return false;
51
+ }
52
+ if (value.project !== undefined && !isAgentScopeConfig(value.project)) {
53
+ return false;
54
+ }
55
+ return true;
56
+ }
57
+ function isAgentConfigFile(value) {
58
+ if (!isRecord(value)) {
59
+ return false;
60
+ }
61
+ if (typeof value.version !== "number") {
62
+ return false;
63
+ }
64
+ const defaults = value.defaults;
65
+ if (!isRecord(defaults)) {
66
+ return false;
67
+ }
68
+ if (defaults.mode !== "auto" && defaults.mode !== "link" && defaults.mode !== "copy") {
69
+ return false;
70
+ }
71
+ if (typeof defaults.profile !== "string") {
72
+ return false;
73
+ }
74
+ if (typeof defaults.sourceRoot !== "string") {
75
+ return false;
76
+ }
77
+ const agents = value.agents;
78
+ if (!isRecord(agents)) {
79
+ return false;
80
+ }
81
+ if (!Object.values(agents).every((entry) => isAgentConfigEntry(entry))) {
82
+ return false;
83
+ }
84
+ const profiles = value.profiles;
85
+ if (profiles !== undefined) {
86
+ if (!isRecord(profiles)) {
87
+ return false;
88
+ }
89
+ for (const profile of Object.values(profiles)) {
90
+ if (!isRecord(profile)) {
91
+ return false;
92
+ }
93
+ if (profile.files !== undefined) {
94
+ if (!Array.isArray(profile.files)) {
95
+ return false;
96
+ }
97
+ if (!profile.files.every((entry) => isMappingFile(entry))) {
98
+ return false;
99
+ }
100
+ }
101
+ }
102
+ }
103
+ return true;
104
+ }
21
105
  function formatYamlError(error, configPath) {
22
106
  if (error instanceof Error) {
23
107
  const linePos = error.linePos?.[0];
@@ -48,6 +132,9 @@ async function readConfig(root) {
48
132
  if (!parsed || typeof parsed !== "object") {
49
133
  throw new errors_1.AgentConfigError(`Invalid config format in ${configPath}`, errors_1.ExitCodes.Validation);
50
134
  }
135
+ if (!isAgentConfigFile(parsed)) {
136
+ throw new errors_1.AgentConfigError(`Invalid config format in ${configPath}`, errors_1.ExitCodes.Validation);
137
+ }
51
138
  return parsed;
52
139
  }
53
140
  async function writeConfig(root, config) {
@@ -7,6 +7,8 @@ exports.ensureDir = ensureDir;
7
7
  exports.fileExists = fileExists;
8
8
  exports.readLinkTarget = readLinkTarget;
9
9
  exports.hashFile = hashFile;
10
+ exports.hashDirectory = hashDirectory;
11
+ exports.hashPath = hashPath;
10
12
  exports.listEntries = listEntries;
11
13
  exports.copyFileOrDir = copyFileOrDir;
12
14
  const crypto_1 = __importDefault(require("crypto"));
@@ -58,6 +60,39 @@ async function hashFile(target) {
58
60
  });
59
61
  return hash.digest("hex");
60
62
  }
63
+ async function hashDirectory(target) {
64
+ const entries = await promises_1.default.readdir(target);
65
+ entries.sort((a, b) => a.localeCompare(b));
66
+ const hash = crypto_1.default.createHash("sha256");
67
+ for (const entry of entries) {
68
+ const fullPath = path_1.default.join(target, entry);
69
+ const stat = await promises_1.default.lstat(fullPath);
70
+ if (stat.isDirectory()) {
71
+ hash.update(`dir:${entry}:`);
72
+ hash.update(await hashDirectory(fullPath));
73
+ continue;
74
+ }
75
+ if (stat.isSymbolicLink()) {
76
+ const linkTarget = await promises_1.default.readlink(fullPath);
77
+ hash.update(`link:${entry}:${linkTarget}`);
78
+ continue;
79
+ }
80
+ hash.update(`file:${entry}:`);
81
+ hash.update(await hashFile(fullPath));
82
+ }
83
+ return hash.digest("hex");
84
+ }
85
+ async function hashPath(target) {
86
+ const stat = await promises_1.default.lstat(target);
87
+ if (stat.isDirectory()) {
88
+ return await hashDirectory(target);
89
+ }
90
+ if (stat.isSymbolicLink()) {
91
+ const linkTarget = await promises_1.default.readlink(target);
92
+ return crypto_1.default.createHash("sha256").update(`link:${linkTarget}`).digest("hex");
93
+ }
94
+ return await hashFile(target);
95
+ }
61
96
  async function listEntries(target) {
62
97
  return await promises_1.default.readdir(target);
63
98
  }
package/dist/status.js CHANGED
@@ -45,7 +45,7 @@ async function getStatus(stateRoot) {
45
45
  results.push({ path: record.path, status: "drifted", reason: "content changed" });
46
46
  continue;
47
47
  }
48
- const currentHash = await (0, filesystem_1.hashFile)(record.path);
48
+ const currentHash = await (0, filesystem_1.hashPath)(record.path);
49
49
  if (currentHash !== record.hash) {
50
50
  results.push({ path: record.path, status: "drifted", reason: "content changed" });
51
51
  continue;
package/dist/sync.js CHANGED
@@ -23,6 +23,7 @@ async function syncConfigs(options) {
23
23
  const skippedMappings = [];
24
24
  let conflictState = { policy: conflictPolicy ?? null, canAsk: true };
25
25
  for (const mapping of resolvedMappings) {
26
+ let allowNonEmptyDir = false;
26
27
  const targetExists = await (0, filesystem_1.fileExists)(mapping.target);
27
28
  if (targetExists && !force && !isManaged(mapping.target, existingState)) {
28
29
  const decision = await resolveConflictPolicy(mapping.target, conflictState);
@@ -39,13 +40,17 @@ async function syncConfigs(options) {
39
40
  if (!dryRun) {
40
41
  await backupTarget(mapping.target, sourceRoot);
41
42
  }
43
+ allowNonEmptyDir = true;
44
+ }
45
+ if (decision.action === "overwrite") {
46
+ allowNonEmptyDir = true;
42
47
  }
43
48
  }
44
49
  if (dryRun) {
45
50
  continue;
46
51
  }
47
52
  try {
48
- await applyMapping(mapping, warnings);
53
+ await applyMapping(mapping, warnings, { allowNonEmptyDir });
49
54
  }
50
55
  catch (error) {
51
56
  if (error instanceof errors_1.AgentConfigError) {
@@ -173,13 +178,14 @@ async function canCreateSymlink() {
173
178
  await promises_1.default.rm(tempRoot, { recursive: true, force: true });
174
179
  }
175
180
  }
176
- async function applyMapping(mapping, warnings) {
181
+ async function applyMapping(mapping, warnings, options) {
182
+ const allowNonEmptyDir = options?.allowNonEmptyDir ?? false;
177
183
  const sourceExists = await (0, filesystem_1.fileExists)(mapping.source);
178
184
  if (!sourceExists) {
179
185
  throw new errors_1.AgentConfigError(`Missing source: ${mapping.source}`, errors_1.ExitCodes.Validation);
180
186
  }
181
187
  if (mapping.mode === "copy") {
182
- await applyCopyMapping(mapping);
188
+ await applyCopyMapping(mapping, { allowNonEmptyDir });
183
189
  return;
184
190
  }
185
191
  await (0, filesystem_1.ensureDir)(path_1.default.dirname(mapping.target));
@@ -194,7 +200,7 @@ async function applyMapping(mapping, warnings) {
194
200
  await promises_1.default.unlink(mapping.target);
195
201
  }
196
202
  else if (existing.isDirectory) {
197
- const removed = await removeEmptyDirectory(mapping.target);
203
+ const removed = await removeDirectory(mapping.target, allowNonEmptyDir);
198
204
  if (!removed) {
199
205
  throw new errors_1.AgentConfigError(`Refusing to replace non-empty directory: ${mapping.target}`, errors_1.ExitCodes.Filesystem);
200
206
  }
@@ -210,21 +216,28 @@ async function applyMapping(mapping, warnings) {
210
216
  throw error;
211
217
  }
212
218
  warnings.push(`Symlink failed for ${mapping.target}; falling back to copy`);
213
- await applyCopyMapping(mapping);
219
+ await applyCopyMapping(mapping, { allowNonEmptyDir });
214
220
  }
215
221
  }
216
- async function applyCopyMapping(mapping) {
222
+ async function applyCopyMapping(mapping, options) {
223
+ const allowNonEmptyDir = options?.allowNonEmptyDir ?? false;
217
224
  const sourceStat = await promises_1.default.lstat(mapping.source);
218
225
  const existing = await getTargetInfo(mapping.target);
219
226
  if (sourceStat.isDirectory()) {
220
227
  if (existing && !existing.isDirectory) {
221
228
  await promises_1.default.unlink(mapping.target);
222
229
  }
230
+ if (existing?.isDirectory) {
231
+ const removed = await removeDirectory(mapping.target, allowNonEmptyDir);
232
+ if (!removed) {
233
+ throw new errors_1.AgentConfigError(`Refusing to replace non-empty directory: ${mapping.target}`, errors_1.ExitCodes.Filesystem);
234
+ }
235
+ }
223
236
  await (0, filesystem_1.copyFileOrDir)(mapping.source, mapping.target);
224
237
  return;
225
238
  }
226
239
  if (existing?.isDirectory) {
227
- const removed = await removeEmptyDirectory(mapping.target);
240
+ const removed = await removeDirectory(mapping.target, allowNonEmptyDir);
228
241
  if (!removed) {
229
242
  throw new errors_1.AgentConfigError(`Refusing to replace non-empty directory: ${mapping.target}`, errors_1.ExitCodes.Filesystem);
230
243
  }
@@ -262,6 +275,13 @@ async function removeEmptyDirectory(target) {
262
275
  await promises_1.default.rmdir(target);
263
276
  return true;
264
277
  }
278
+ async function removeDirectory(target, allowNonEmptyDir) {
279
+ if (!allowNonEmptyDir) {
280
+ return await removeEmptyDirectory(target);
281
+ }
282
+ await promises_1.default.rm(target, { recursive: true, force: true });
283
+ return true;
284
+ }
265
285
  function isManaged(target, state) {
266
286
  if (!state) {
267
287
  return false;
@@ -281,7 +301,7 @@ async function buildState(stateRoot, mode, projectRoot, mappings, previousState)
281
301
  mode: modeValue,
282
302
  size: stat.size,
283
303
  mtimeMs: stat.mtimeMs,
284
- hash: modeValue === "copy" ? await (0, filesystem_1.hashFile)(mapping.target) : null,
304
+ hash: modeValue === "copy" ? await (0, filesystem_1.hashPath)(mapping.target) : null,
285
305
  linkTarget: modeValue === "link" ? await (0, filesystem_1.readLinkTarget)(mapping.target) : null
286
306
  };
287
307
  files[mapping.target] = record;
package/dist/templates.js CHANGED
@@ -16,6 +16,9 @@ function createDefaultConfig() {
16
16
  root: "~/.claude",
17
17
  files: [
18
18
  { source: "agent.md", target: "CLAUDE.md" },
19
+ { source: "claude/settings.json", target: "settings.json" },
20
+ { source: "claude/agents/", target: "agents/" },
21
+ { source: "claude/commands/", target: "commands/" },
19
22
  { source: "skills/", target: "skills/" }
20
23
  ]
21
24
  },
@@ -23,6 +26,9 @@ function createDefaultConfig() {
23
26
  root: "<project-root>",
24
27
  files: [
25
28
  { source: "agent.md", target: "CLAUDE.md" },
29
+ { source: "claude/settings.json", target: ".claude/settings.json" },
30
+ { source: "claude/agents/", target: ".claude/agents/" },
31
+ { source: "claude/commands/", target: ".claude/commands/" },
26
32
  { source: "rules/", target: ".claude/rules/" },
27
33
  { source: "skills/", target: ".claude/skills/" }
28
34
  ]
@@ -34,14 +40,14 @@ function createDefaultConfig() {
34
40
  root: "${CODEX_HOME:-~/.codex}",
35
41
  files: [
36
42
  { source: "agent.md", target: "AGENTS.md" },
37
- { source: "skills/", target: "skills/" }
43
+ { source: "skills/", target: "../.agents/skills/" }
38
44
  ]
39
45
  },
40
46
  project: {
41
47
  root: "<project-root>",
42
48
  files: [
43
49
  { source: "agent.md", target: "AGENTS.md" },
44
- { source: "skills/", target: ".codex/skills/" }
50
+ { source: "skills/", target: ".agents/skills/" }
45
51
  ]
46
52
  }
47
53
  },
@@ -49,12 +55,18 @@ function createDefaultConfig() {
49
55
  displayName: "Cursor",
50
56
  global: {
51
57
  root: "~/.cursor",
52
- files: [{ source: "skills/", target: "skills/" }]
58
+ files: [
59
+ { source: "cursor/hooks.json", target: "hooks.json" },
60
+ { source: "cursor/hooks/", target: "hooks/" },
61
+ { source: "skills/", target: "skills/" }
62
+ ]
53
63
  },
54
64
  project: {
55
65
  root: "<project-root>",
56
66
  files: [
57
67
  { source: "agent.md", target: "AGENTS.md" },
68
+ { source: "cursor/hooks.json", target: ".cursor/hooks.json" },
69
+ { source: "cursor/hooks/", target: ".cursor/hooks/" },
58
70
  { source: "rules/", target: ".cursor/rules/" },
59
71
  { source: "skills/", target: ".cursor/skills/" }
60
72
  ]
@@ -66,6 +78,8 @@ function createDefaultConfig() {
66
78
  root: "~/.config/opencode",
67
79
  files: [
68
80
  { source: "agent.md", target: "AGENTS.md" },
81
+ { source: "agents/", target: "agents/" },
82
+ { source: "commands/", target: "commands/" },
69
83
  { source: "rules/", target: "rules/" },
70
84
  { source: "skills/", target: "skills/" }
71
85
  ]
@@ -74,6 +88,8 @@ function createDefaultConfig() {
74
88
  root: "<project-root>",
75
89
  files: [
76
90
  { source: "agent.md", target: "AGENTS.md" },
91
+ { source: "agents/", target: ".opencode/agents/" },
92
+ { source: "commands/", target: ".opencode/commands/" },
77
93
  { source: "rules/", target: ".opencode/rules/" },
78
94
  { source: "skills/", target: ".opencode/skills/" }
79
95
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shruubi/agentconfig",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Sync a single agent config source to multiple coding agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -15,7 +15,7 @@
15
15
  "LICENSE"
16
16
  ],
17
17
  "engines": {
18
- "node": ">=20"
18
+ "node": ">=22"
19
19
  },
20
20
  "bin": {
21
21
  "agentconfig": "dist/cli.js"
@@ -28,7 +28,10 @@
28
28
  "format": "prettier --write .",
29
29
  "format:check": "prettier --check .",
30
30
  "prepare": "husky",
31
- "test": "npm run build && node --test",
31
+ "test": "npm run build && npm run test:compile && node --test dist-test/tests/*.js",
32
+ "test:compile": "tsc -p tsconfig.test.json",
33
+ "test:dev": "node --test --import tsx tests/*.ts",
34
+ "test:dev:watch": "node --test --watch --import tsx tests/*.ts",
32
35
  "dev": "tsc -w"
33
36
  },
34
37
  "dependencies": {
@@ -44,6 +47,7 @@
44
47
  "lint-staged": "^15.2.11",
45
48
  "prettier": "^3.5.2",
46
49
  "typescript-eslint": "^8.26.0",
50
+ "tsx": "^4.19.2",
47
51
  "typescript": "^5.7.3"
48
52
  },
49
53
  "lint-staged": {