@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 +9 -0
- package/dist/config.js +87 -0
- package/dist/filesystem.js +35 -0
- package/dist/status.js +1 -1
- package/dist/sync.js +28 -8
- package/dist/templates.js +19 -3
- package/package.json +7 -3
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) {
|
package/dist/filesystem.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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.
|
|
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: ".
|
|
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: [
|
|
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.
|
|
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": ">=
|
|
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": {
|