@sayansr26/agent-os 0.5.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/.claude-plugin/marketplace.json +27 -0
- package/CHANGELOG.md +203 -0
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/bin/agent-os.mjs +6 -0
- package/package.json +49 -0
- package/plugins/agent-os/.claude-plugin/plugin.json +18 -0
- package/plugins/agent-os/agents/architect.md +77 -0
- package/plugins/agent-os/agents/builder.md +90 -0
- package/plugins/agent-os/agents/documenter.md +87 -0
- package/plugins/agent-os/agents/feature-cartographer.md +142 -0
- package/plugins/agent-os/agents/orchestrator.md +96 -0
- package/plugins/agent-os/agents/reviewer.md +82 -0
- package/plugins/agent-os/agents/tester.md +83 -0
- package/plugins/agent-os/hooks/hooks.json +17 -0
- package/plugins/agent-os/hooks/session-resume.mjs +136 -0
- package/plugins/agent-os/skills/init/SKILL.md +134 -0
- package/plugins/agent-os/skills/init/references/changing-a-feature.md +104 -0
- package/plugins/agent-os/skills/init/references/establishing.md +128 -0
- package/plugins/agent-os/skills/init/references/git-permissions.md +118 -0
- package/plugins/agent-os/skills/init/references/migrating.md +43 -0
- package/plugins/agent-os/skills/init/references/writing-rules.md +50 -0
- package/plugins/agent-os/skills/init/scripts/audit.mjs +328 -0
- package/plugins/agent-os/skills/map/SKILL.md +67 -0
- package/plugins/agent-os/skills/memory/SKILL.md +82 -0
- package/plugins/agent-os/skills/memory/scripts/memory.mjs +97 -0
- package/src/cli.mjs +157 -0
- package/src/detect.mjs +54 -0
- package/src/selftest.mjs +71 -0
- package/src/source.mjs +102 -0
- package/src/targets.mjs +186 -0
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { detect, summarise, TOOLS } from "./detect.mjs";
|
|
6
|
+
import { load, write, readIfExists, matches, DIR } from "./source.mjs";
|
|
7
|
+
import { compile, TARGETS } from "./targets.mjs";
|
|
8
|
+
|
|
9
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
10
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
11
|
+
|
|
12
|
+
const USAGE = `
|
|
13
|
+
${bold("agent-os")} — one source of truth for AI coding agent config
|
|
14
|
+
|
|
15
|
+
agent-os init detect your tools, create .agent-os/, compile
|
|
16
|
+
agent-os sync recompile after editing .agent-os/
|
|
17
|
+
agent-os check verify nothing drifted (exit 1 if it has) — for CI
|
|
18
|
+
agent-os detect list which tools this project is set up for
|
|
19
|
+
agent-os audit inspect the project's context layer and report findings
|
|
20
|
+
agent-os memory inspect and health-check every memory store
|
|
21
|
+
|
|
22
|
+
Without a global install, prefix any of these with
|
|
23
|
+
${dim("npx @sayansr26/agent-os")}
|
|
24
|
+
|
|
25
|
+
Options
|
|
26
|
+
--root <dir> project directory (default: cwd)
|
|
27
|
+
--dry-run print what would change, write nothing
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
function scaffold(root, found) {
|
|
31
|
+
const present = found.filter((t) => t.present).map((t) => t.id);
|
|
32
|
+
mkdirSync(join(root, DIR, "rules"), { recursive: true });
|
|
33
|
+
if (!existsSync(join(root, DIR, "config.json")))
|
|
34
|
+
write(root, `${DIR}/config.json`, JSON.stringify({ targets: present.length ? present : ["claude-code"] }, null, 2) + "\n");
|
|
35
|
+
if (!existsSync(join(root, DIR, "AGENTS.md")))
|
|
36
|
+
write(root, `${DIR}/AGENTS.md`, `# Project instructions
|
|
37
|
+
|
|
38
|
+
Replace this with what is true in **every** session: how to build, how to run,
|
|
39
|
+
how to verify, and the conventions that are not obvious from the code.
|
|
40
|
+
|
|
41
|
+
Keep it short. Anything that only matters for part of the tree belongs in
|
|
42
|
+
\`.agent-os/rules/\` instead, where it can be scoped to the files it applies to.
|
|
43
|
+
`);
|
|
44
|
+
mkdirSync(join(root, DIR, "skills"), { recursive: true });
|
|
45
|
+
if (!existsSync(join(root, DIR, "rules", "example.md")))
|
|
46
|
+
write(root, `${DIR}/rules/example.md`, `---
|
|
47
|
+
description: Conventions for the API layer
|
|
48
|
+
paths:
|
|
49
|
+
- "src/api/**"
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
Delete this file once you have written a real one.
|
|
53
|
+
|
|
54
|
+
A rule with \`paths:\` is loaded only when the agent touches a matching file, in
|
|
55
|
+
every tool that supports conditional loading. Tools that do not support it get
|
|
56
|
+
these as a referenced list instead of always-on text.
|
|
57
|
+
`);
|
|
58
|
+
return present;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function main(argv) {
|
|
62
|
+
const cmd = argv.find((a) => !a.startsWith("-")) || "help";
|
|
63
|
+
const root = (() => { const i = argv.indexOf("--root"); return i === -1 ? process.cwd() : argv[i + 1]; })();
|
|
64
|
+
const dry = argv.includes("--dry-run");
|
|
65
|
+
|
|
66
|
+
if (cmd === "help" || argv.includes("-h") || argv.includes("--help")) { console.log(USAGE); return; }
|
|
67
|
+
|
|
68
|
+
const found = detect(root);
|
|
69
|
+
|
|
70
|
+
if (cmd === "detect") {
|
|
71
|
+
console.log(`\n${bold("Tools detected")} ${root}\n`);
|
|
72
|
+
console.log(summarise(found));
|
|
73
|
+
console.log(`\n${dim("rules: native = same paths: schema · translate = converted · none = AGENTS.md + instructions list")}\n`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Deterministic inspections. Both are plain scripts, so they run anywhere —
|
|
78
|
+
// unlike `map`, which dispatches an agent and therefore needs a model, not a
|
|
79
|
+
// CLI. That one stays a Claude Code skill.
|
|
80
|
+
const PKG = join(fileURLToPath(new URL(".", import.meta.url)), "..");
|
|
81
|
+
const runScript = (rel, args) => {
|
|
82
|
+
const script = join(PKG, rel);
|
|
83
|
+
if (!existsSync(script)) throw new Error(`missing bundled script: ${rel}`);
|
|
84
|
+
const r = spawnSync(process.execPath, [script, ...args], { stdio: "inherit" });
|
|
85
|
+
if (r.error) throw r.error;
|
|
86
|
+
process.exitCode = r.status ?? 0;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
if (cmd === "audit") {
|
|
90
|
+
console.log(`\n${bold("Tools detected")}\n`);
|
|
91
|
+
console.log(summarise(found));
|
|
92
|
+
runScript("plugins/agent-os/skills/init/scripts/audit.mjs", [root]);
|
|
93
|
+
console.log(dim("\nThe audit above is written around Claude Code's layout (CLAUDE.md,"));
|
|
94
|
+
console.log(dim(".claude/rules/, hooks, agent memory). For the other tools, `agent-os check`"));
|
|
95
|
+
console.log(dim("is the one that matters — it verifies their generated config matches source.\n"));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (cmd === "memory") {
|
|
100
|
+
runScript("plugins/agent-os/skills/memory/scripts/memory.mjs", argv.includes("--stale") ? [root, "--stale"] : [root]);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (cmd === "init") {
|
|
105
|
+
console.log(`\n${bold("agent-os init")} ${root}\n`);
|
|
106
|
+
console.log(summarise(found));
|
|
107
|
+
const present = scaffold(root, found);
|
|
108
|
+
console.log(`\n created ${DIR}/ ${dim("(config.json, AGENTS.md, rules/example.md)")}`);
|
|
109
|
+
if (!present.length) console.log(` ${dim("no tools detected — defaulting to claude-code; edit .agent-os/config.json")}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (cmd === "init" || cmd === "sync" || cmd === "check") {
|
|
113
|
+
const src = load(root);
|
|
114
|
+
if (!src) throw new Error(`no ${DIR}/ here. Run \`agent-os init\` first.`);
|
|
115
|
+
const targets = (src.config.targets || []).filter((t) => TARGETS[t]);
|
|
116
|
+
const unknown = (src.config.targets || []).filter((t) => !TARGETS[t]);
|
|
117
|
+
if (unknown.length) console.log(` ${dim(`ignoring unknown target(s): ${unknown.join(", ")}`)}`);
|
|
118
|
+
|
|
119
|
+
const files = compile(src, targets, (rel) => readIfExists(root, rel));
|
|
120
|
+
|
|
121
|
+
if (cmd === "check") {
|
|
122
|
+
const state = files.map((f) => ({ f, m: matches(root, f.path, f.content) }));
|
|
123
|
+
const drifted = state.filter((s) => s.m !== true);
|
|
124
|
+
console.log(`\n${bold("agent-os check")} ${root}\n`);
|
|
125
|
+
for (const { f, m } of state)
|
|
126
|
+
console.log(` ${m === true ? "ok " : m === null ? "MISSING" : "DRIFTED"} ${f.path}`);
|
|
127
|
+
if (drifted.length) {
|
|
128
|
+
console.log(`\n${drifted.length} file(s) out of date. Run \`agent-os sync\`.\n`);
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
} else console.log(`\nAll ${files.length} generated file(s) match the source.\n`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log(`\n${bold(dry ? "would write" : "wrote")} ${dim(`${src.rules.length} rule(s) -> ${targets.length} tool(s) + AGENTS.md`)}\n`);
|
|
135
|
+
const byTarget = {};
|
|
136
|
+
for (const f of files) (byTarget[f.target] ||= []).push(f);
|
|
137
|
+
const labelFor = (t) => {
|
|
138
|
+
if (t === "universal") return "AGENTS.md";
|
|
139
|
+
if (t.startsWith("skills:")) {
|
|
140
|
+
const ids = t.slice(7).split("+").map((i) => TARGETS[i]?.label || i);
|
|
141
|
+
return `skills → ${ids.join(", ")}`;
|
|
142
|
+
}
|
|
143
|
+
return TARGETS[t]?.label || t;
|
|
144
|
+
};
|
|
145
|
+
for (const [t, fs_] of Object.entries(byTarget)) {
|
|
146
|
+
console.log(` ${labelFor(t).padEnd(38)} ${fs_.length} file(s)`);
|
|
147
|
+
for (const f of fs_) {
|
|
148
|
+
if (!dry) write(root, f.path, f.content);
|
|
149
|
+
console.log(` ${f.path}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
console.log(`\n${dim("Generated files carry a banner. Edit .agent-os/ and re-run sync; never edit them directly.")}\n`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
console.log(USAGE);
|
|
157
|
+
}
|
package/src/detect.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which agent tools is this project already set up for?
|
|
3
|
+
*
|
|
4
|
+
* Detection is by evidence on disk, never by asking. A tool counts as present
|
|
5
|
+
* if its config exists here — that is the only signal that survives a fresh
|
|
6
|
+
* clone, and the only one that matters for deciding what to compile.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
|
|
12
|
+
const HOME = homedir();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `rules` — can the tool scope instructions to file globs?
|
|
16
|
+
* "native" same `paths:` array as the canonical source; copy verbatim
|
|
17
|
+
* "translate" supports scoping with a different schema; must be converted
|
|
18
|
+
* "none" no conditional loading; gets AGENTS.md and an instructions list
|
|
19
|
+
*/
|
|
20
|
+
export const TOOLS = [
|
|
21
|
+
{ id: "claude-code", name: "Claude Code", rules: "native",
|
|
22
|
+
marks: [".claude", "CLAUDE.md"], home: [".claude"] },
|
|
23
|
+
{ id: "cline", name: "Cline", rules: "native",
|
|
24
|
+
marks: [".clinerules", ".cline"], home: [".cline"] },
|
|
25
|
+
{ id: "cursor", name: "Cursor", rules: "translate",
|
|
26
|
+
marks: [".cursor"], home: [".cursor"] },
|
|
27
|
+
{ id: "windsurf", name: "Windsurf", rules: "translate",
|
|
28
|
+
marks: [".windsurf", ".windsurfrules", ".devin"], home: [".codeium/windsurf"] },
|
|
29
|
+
{ id: "antigravity", name: "Antigravity", rules: "translate",
|
|
30
|
+
marks: [".agents/rules", ".agent/rules"], home: [".gemini/config"] },
|
|
31
|
+
{ id: "gemini-cli", name: "Gemini CLI", rules: "none",
|
|
32
|
+
marks: [".gemini", "GEMINI.md"], home: [".gemini"] },
|
|
33
|
+
{ id: "opencode", name: "OpenCode", rules: "none",
|
|
34
|
+
marks: [".opencode", "opencode.json", "opencode.jsonc"], home: [".config/opencode"] },
|
|
35
|
+
{ id: "kilo", name: "Kilo", rules: "none",
|
|
36
|
+
marks: [".kilo", "kilo.json", "kilo.jsonc", ".kilocode"], home: [".config/kilo"] },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export function detect(root) {
|
|
40
|
+
return TOOLS.map((t) => {
|
|
41
|
+
const inProject = t.marks.some((m) => existsSync(join(root, m)));
|
|
42
|
+
const onMachine = (t.home || []).some((h) => existsSync(join(HOME, h)));
|
|
43
|
+
return { ...t, inProject, onMachine, present: inProject || onMachine };
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function summarise(found) {
|
|
48
|
+
const lines = [];
|
|
49
|
+
for (const t of found) {
|
|
50
|
+
const where = t.inProject ? "project" : t.onMachine ? "machine only" : "—";
|
|
51
|
+
lines.push(` ${t.present ? "✓" : " "} ${t.name.padEnd(14)} ${where.padEnd(14)} rules: ${t.rules}`);
|
|
52
|
+
}
|
|
53
|
+
return lines.join("\n");
|
|
54
|
+
}
|
package/src/selftest.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** End-to-end: scaffold a throwaway project, compile all 8 targets, assert the
|
|
3
|
+
* output matches each tool's real schema, and prove drift is detected. */
|
|
4
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const PKG = join(fileURLToPath(new URL(".", import.meta.url)), "..");
|
|
11
|
+
const cli = (args, cwd) => spawnSync(process.execPath, [join(PKG, "bin/agent-os.mjs"), ...args, "--root", cwd], { encoding: "utf8" });
|
|
12
|
+
let fail = 0;
|
|
13
|
+
const ok = (c, m) => { console.log(` ${c ? "ok " : "FAIL"} ${m}`); if (!c) fail++; };
|
|
14
|
+
|
|
15
|
+
const root = mkdtempSync(join(tmpdir(), "agent-os-"));
|
|
16
|
+
try {
|
|
17
|
+
mkdirSync(join(root, ".agent-os/rules"), { recursive: true });
|
|
18
|
+
mkdirSync(join(root, ".agent-os/skills/demo"), { recursive: true });
|
|
19
|
+
writeFileSync(join(root, ".agent-os/config.json"), JSON.stringify({
|
|
20
|
+
targets: ["claude-code","cline","cursor","windsurf","antigravity","gemini-cli","opencode","kilo"] }));
|
|
21
|
+
writeFileSync(join(root, ".agent-os/AGENTS.md"), "# Test project\n\nBuild with `make`.\n");
|
|
22
|
+
writeFileSync(join(root, ".agent-os/rules/api.md"),
|
|
23
|
+
'---\ndescription: API conventions\npaths:\n - "src/api/**"\n---\n\nValidate at the boundary.\n');
|
|
24
|
+
writeFileSync(join(root, ".agent-os/rules/global.md"),
|
|
25
|
+
"---\ndescription: Applies everywhere\nalways: true\n---\n\nNever commit secrets.\n");
|
|
26
|
+
writeFileSync(join(root, ".agent-os/skills/demo/SKILL.md"),
|
|
27
|
+
"---\nname: demo\ndescription: A demo skill.\n---\n\nDo the thing.\n");
|
|
28
|
+
|
|
29
|
+
const r = cli(["sync"], root);
|
|
30
|
+
ok(r.status === 0, `sync exits 0 ${r.status === 0 ? "" : "\n" + r.stderr}`);
|
|
31
|
+
const read = (p) => existsSync(join(root, p)) ? readFileSync(join(root, p), "utf8") : null;
|
|
32
|
+
|
|
33
|
+
console.log("\n each target gets its own schema");
|
|
34
|
+
ok(read(".claude/rules/api.md")?.includes('paths:\n - "src/api/**"'), "Claude Code: paths: verbatim");
|
|
35
|
+
ok(read(".clinerules/api.md")?.includes('paths:\n - "src/api/**"'), "Cline: paths: verbatim");
|
|
36
|
+
const cur = read(".cursor/rules/api.mdc");
|
|
37
|
+
ok(cur?.includes("globs: src/api/**") && cur.includes("alwaysApply: false"), "Cursor: globs + alwaysApply");
|
|
38
|
+
ok(read(".cursor/rules/global.mdc")?.includes("alwaysApply: true"), "Cursor: always rule -> alwaysApply: true");
|
|
39
|
+
ok(read(".windsurf/rules/api.md")?.includes("trigger: glob"), "Windsurf: trigger: glob");
|
|
40
|
+
ok(read(".windsurf/rules/global.md")?.includes("trigger: always_on"), "Windsurf: always rule -> always_on");
|
|
41
|
+
ok(read(".agents/rules/api.md")?.includes("Intended activation: Glob"), "Antigravity: scope stated, no invented syntax");
|
|
42
|
+
ok(JSON.parse(read(".gemini/settings.json")).context.fileName.includes("AGENTS.md"), "Gemini CLI: context.fileName");
|
|
43
|
+
ok(JSON.parse(read("opencode.json")).instructions.some((i) => i.includes("api.md")), "OpenCode: instructions array");
|
|
44
|
+
ok(JSON.parse(read("kilo.json")).instructions.some((i) => i.includes("api.md")), "Kilo: instructions array");
|
|
45
|
+
|
|
46
|
+
console.log("\n universal + skills");
|
|
47
|
+
const am = read("AGENTS.md");
|
|
48
|
+
ok(am?.includes("Never commit secrets"), "AGENTS.md inlines always-on rules");
|
|
49
|
+
ok(am?.includes("src/api/**"), "AGENTS.md lists scoped rules rather than inlining");
|
|
50
|
+
ok(read(".agents/skills/demo/SKILL.md")?.includes("name: demo"), "skills -> .agents/skills (4 vendors)");
|
|
51
|
+
ok(read(".claude/skills/demo/SKILL.md") !== null, "skills -> .claude/skills");
|
|
52
|
+
ok(read(".cline/skills/demo/SKILL.md") !== null, "skills -> .cline/skills");
|
|
53
|
+
|
|
54
|
+
console.log("\n drift");
|
|
55
|
+
ok(cli(["check"], root).status === 0, "check passes when in sync");
|
|
56
|
+
writeFileSync(join(root, ".cursor/rules/api.mdc"), "hand edited\n");
|
|
57
|
+
const d = cli(["check"], root);
|
|
58
|
+
ok(d.status === 1, "check exits 1 on drift");
|
|
59
|
+
ok(d.stdout.includes("DRIFTED"), "check names the drifted file");
|
|
60
|
+
ok(cli(["sync"], root).status === 0 && cli(["check"], root).status === 0, "sync repairs drift");
|
|
61
|
+
|
|
62
|
+
console.log("\n merge, not overwrite");
|
|
63
|
+
writeFileSync(join(root, "opencode.json"), JSON.stringify({ model: "anthropic/x", instructions: ["KEEP.md"] }, null, 2));
|
|
64
|
+
cli(["sync"], root);
|
|
65
|
+
const oc = JSON.parse(read("opencode.json"));
|
|
66
|
+
ok(oc.model === "anthropic/x", "existing keys preserved");
|
|
67
|
+
ok(oc.instructions.includes("KEEP.md"), "existing instructions preserved");
|
|
68
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
69
|
+
|
|
70
|
+
console.log(`\n${fail ? `FAILED (${fail})` : "PASSED"}\n`);
|
|
71
|
+
process.exit(fail ? 1 : 0);
|
package/src/source.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical source. One place you edit; everything else is generated.
|
|
3
|
+
*
|
|
4
|
+
* .agent-os/
|
|
5
|
+
* config.json { targets: [...] }
|
|
6
|
+
* AGENTS.md instructions that apply everywhere
|
|
7
|
+
* rules/<name>.md --- paths: [globs] | always: true | description: ... ---
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync, statSync } from "node:fs";
|
|
10
|
+
import { join, dirname, basename } from "node:path";
|
|
11
|
+
|
|
12
|
+
export const DIR = ".agent-os";
|
|
13
|
+
export const BANNER = "agent-os: generated from .agent-os/ — edit the source, then run `npx @sayansr26/agent-os sync`";
|
|
14
|
+
|
|
15
|
+
export function parseFrontmatter(text) {
|
|
16
|
+
if (!text.startsWith("---\n")) return { fields: {}, body: text };
|
|
17
|
+
const end = text.indexOf("\n---\n", 3);
|
|
18
|
+
if (end === -1) return { fields: {}, body: text };
|
|
19
|
+
const block = text.slice(4, end + 1);
|
|
20
|
+
const body = text.slice(end + 5).replace(/^\n+/, "");
|
|
21
|
+
const fields = {};
|
|
22
|
+
let key = null;
|
|
23
|
+
for (const line of block.split("\n")) {
|
|
24
|
+
const kv = line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/);
|
|
25
|
+
const item = line.match(/^\s*-\s+(.*)$/);
|
|
26
|
+
if (kv) { key = kv[1]; const v = kv[2].trim(); fields[key] = v === "" ? [] : v; }
|
|
27
|
+
else if (item && key) {
|
|
28
|
+
if (!Array.isArray(fields[key])) fields[key] = [];
|
|
29
|
+
fields[key].push(item[1].trim().replace(/^["']|["']$/g, ""));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { fields, body };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function load(root) {
|
|
36
|
+
const dir = join(root, DIR);
|
|
37
|
+
if (!existsSync(dir)) return null;
|
|
38
|
+
const cfgPath = join(dir, "config.json");
|
|
39
|
+
const config = existsSync(cfgPath) ? JSON.parse(readFileSync(cfgPath, "utf8")) : { targets: [] };
|
|
40
|
+
const agents = existsSync(join(dir, "AGENTS.md")) ? readFileSync(join(dir, "AGENTS.md"), "utf8") : "";
|
|
41
|
+
const rulesDir = join(dir, "rules");
|
|
42
|
+
const rules = (existsSync(rulesDir) ? readdirSync(rulesDir) : [])
|
|
43
|
+
.filter((f) => f.endsWith(".md"))
|
|
44
|
+
.map((f) => {
|
|
45
|
+
const { fields, body } = parseFrontmatter(readFileSync(join(rulesDir, f), "utf8"));
|
|
46
|
+
const paths = Array.isArray(fields.paths) ? fields.paths : fields.paths ? [fields.paths] : [];
|
|
47
|
+
return {
|
|
48
|
+
name: basename(f, ".md"),
|
|
49
|
+
description: typeof fields.description === "string" ? fields.description : "",
|
|
50
|
+
paths,
|
|
51
|
+
always: String(fields.always) === "true" || paths.length === 0,
|
|
52
|
+
body: body.trim(),
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
// Skills: a directory per skill, SKILL.md plus any supporting files.
|
|
56
|
+
const skillsDir = join(dir, "skills");
|
|
57
|
+
const skills = (existsSync(skillsDir) ? readdirSync(skillsDir) : [])
|
|
58
|
+
.filter((d) => existsSync(join(skillsDir, d, "SKILL.md")))
|
|
59
|
+
.map((d) => ({ name: d, files: walkRel(join(skillsDir, d)) }));
|
|
60
|
+
|
|
61
|
+
return { root, config, agents: agents.trim(), rules, skills };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function write(root, rel, content) {
|
|
65
|
+
const p = join(root, rel);
|
|
66
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
67
|
+
writeFileSync(p, content);
|
|
68
|
+
return rel;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function readIfExists(root, rel) {
|
|
72
|
+
const p = join(root, rel);
|
|
73
|
+
return existsSync(p) ? readFileSync(p, "utf8") : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compare a generated file against what is on disk.
|
|
78
|
+
*
|
|
79
|
+
* Skill files are read as Buffers by `walkRel` so that a skill can ship a PNG
|
|
80
|
+
* or a zip without being mangled, while rule files are generated as strings.
|
|
81
|
+
* Comparing the two kinds with `!==` reports every skill file as drifted even
|
|
82
|
+
* immediately after a sync, so the comparison is done per kind here.
|
|
83
|
+
*
|
|
84
|
+
* Returns `null` when the file does not exist, otherwise a boolean.
|
|
85
|
+
*/
|
|
86
|
+
export function matches(root, rel, content) {
|
|
87
|
+
const p = join(root, rel);
|
|
88
|
+
if (!existsSync(p)) return null;
|
|
89
|
+
const cur = readFileSync(p);
|
|
90
|
+
return Buffer.isBuffer(content) ? cur.equals(content) : cur.toString("utf8") === content;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Every file under `dir`, as paths relative to it. */
|
|
94
|
+
export function walkRel(dir, prefix = "") {
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const e of readdirSync(dir)) {
|
|
97
|
+
const abs = join(dir, e);
|
|
98
|
+
if (statSync(abs).isDirectory()) out.push(...walkRel(abs, prefix ? `${prefix}/${e}` : e));
|
|
99
|
+
else out.push({ rel: prefix ? `${prefix}/${e}` : e, content: readFileSync(abs) });
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
package/src/targets.mjs
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One adapter per tool. Each turns the canonical source into that tool's own
|
|
3
|
+
* format. The schemas genuinely differ — this file is where that cost is paid
|
|
4
|
+
* once instead of by every user.
|
|
5
|
+
*
|
|
6
|
+
* Every adapter returns [{ path, content }]. Adapters that must merge into an
|
|
7
|
+
* existing JSON config receive the current contents and return the merged file.
|
|
8
|
+
*/
|
|
9
|
+
import { BANNER } from "./source.mjs";
|
|
10
|
+
|
|
11
|
+
const hdr = (comment) => `${comment} ${BANNER}\n`;
|
|
12
|
+
const mdHeader = `<!-- ${BANNER} -->\n\n`;
|
|
13
|
+
|
|
14
|
+
/** Rules whose `paths` are empty apply everywhere. */
|
|
15
|
+
const scoped = (rules) => rules.filter((r) => !r.always);
|
|
16
|
+
const universal = (rules) => rules.filter((r) => r.always);
|
|
17
|
+
|
|
18
|
+
/** AGENTS.md — the one format 35+ tools read natively. */
|
|
19
|
+
function agentsMd(src) {
|
|
20
|
+
const parts = [src.agents];
|
|
21
|
+
for (const r of universal(src.rules)) parts.push(`## ${r.name}\n\n${r.body}`);
|
|
22
|
+
const s = scoped(src.rules);
|
|
23
|
+
if (s.length) {
|
|
24
|
+
parts.push(
|
|
25
|
+
"## Path-scoped rules\n\n" +
|
|
26
|
+
"These apply only to matching files. Tools with conditional rule loading\n" +
|
|
27
|
+
"receive them as real scoped rules; read the relevant one before editing.\n\n" +
|
|
28
|
+
s.map((r) => `- \`${r.paths.join("`, `")}\` — ${r.description || r.name}`).join("\n")
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return parts.filter(Boolean).join("\n\n") + "\n";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const TARGETS = {
|
|
35
|
+
// ---- native `paths:` — copy verbatim -------------------------------------
|
|
36
|
+
"claude-code": {
|
|
37
|
+
label: "Claude Code",
|
|
38
|
+
files: (src) => scoped(src.rules).map((r) => ({
|
|
39
|
+
path: `.claude/rules/${r.name}.md`,
|
|
40
|
+
content: `---\npaths:\n${r.paths.map((p) => ` - "${p}"`).join("\n")}\n---\n\n${mdHeader}${r.body}\n`,
|
|
41
|
+
})),
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
cline: {
|
|
45
|
+
label: "Cline",
|
|
46
|
+
files: (src) => scoped(src.rules).map((r) => ({
|
|
47
|
+
path: `.clinerules/${r.name}.md`,
|
|
48
|
+
content: `---\npaths:\n${r.paths.map((p) => ` - "${p}"`).join("\n")}\n---\n\n${mdHeader}${r.body}\n`,
|
|
49
|
+
})),
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
// ---- translate ----------------------------------------------------------
|
|
53
|
+
cursor: {
|
|
54
|
+
label: "Cursor",
|
|
55
|
+
// .mdc, and a rule without frontmatter is ignored entirely.
|
|
56
|
+
files: (src) => src.rules.map((r) => ({
|
|
57
|
+
path: `.cursor/rules/${r.name}.mdc`,
|
|
58
|
+
content: `---\ndescription: ${r.description || r.name}\n` +
|
|
59
|
+
(r.always ? "alwaysApply: true\n" : `globs: ${r.paths.join(",")}\nalwaysApply: false\n`) +
|
|
60
|
+
`---\n\n${mdHeader}${r.body}\n`,
|
|
61
|
+
})),
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
windsurf: {
|
|
65
|
+
label: "Windsurf",
|
|
66
|
+
// `trigger:` rather than `alwaysApply:`. 12,000 char cap per file.
|
|
67
|
+
files: (src) => src.rules.map((r) => {
|
|
68
|
+
const body = r.body.length > 11500 ? r.body.slice(0, 11500) + "\n\n<!-- truncated: Windsurf caps rule files at 12,000 characters -->" : r.body;
|
|
69
|
+
return {
|
|
70
|
+
path: `.windsurf/rules/${r.name}.md`,
|
|
71
|
+
content: `---\ntrigger: ${r.always ? "always_on" : "glob"}\n` +
|
|
72
|
+
(r.always ? "" : `globs: ${r.paths.join(",")}\n`) +
|
|
73
|
+
`description: ${r.description || r.name}\n---\n\n${mdHeader}${body}\n`,
|
|
74
|
+
};
|
|
75
|
+
}),
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
antigravity: {
|
|
79
|
+
label: "Antigravity",
|
|
80
|
+
// Glob activation exists but is set in the Customizations UI — the on-disk
|
|
81
|
+
// syntax is undocumented. We write plain Markdown (which is what the docs
|
|
82
|
+
// specify a rule is) and state the intended scope in the file so it can be
|
|
83
|
+
// set by hand. 12,000 char cap.
|
|
84
|
+
files: (src) => src.rules.map((r) => {
|
|
85
|
+
const body = r.body.length > 11000 ? r.body.slice(0, 11000) + "\n\n<!-- truncated: Antigravity caps rule files at 12,000 characters -->" : r.body;
|
|
86
|
+
const scope = r.always
|
|
87
|
+
? "Intended activation: Always On."
|
|
88
|
+
: `Intended activation: Glob — ${r.paths.join(", ")}\nSet this in Customizations → Rules; Antigravity has no documented file syntax for it.`;
|
|
89
|
+
return {
|
|
90
|
+
path: `.agents/rules/${r.name}.md`,
|
|
91
|
+
content: `${mdHeader}<!-- ${scope.replace(/\n/g, " ")} -->\n\n# ${r.description || r.name}\n\n${body}\n`,
|
|
92
|
+
};
|
|
93
|
+
}),
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// ---- no conditional loading: AGENTS.md plus an instructions list ---------
|
|
97
|
+
"gemini-cli": {
|
|
98
|
+
label: "Gemini CLI",
|
|
99
|
+
// Reads GEMINI.md by default; AGENTS.md only if context.fileName says so.
|
|
100
|
+
merge: (src, existing) => {
|
|
101
|
+
const cfg = existing ? JSON.parse(existing) : {};
|
|
102
|
+
const names = new Set([].concat(cfg.context?.fileName || ["GEMINI.md"]));
|
|
103
|
+
names.add("AGENTS.md");
|
|
104
|
+
cfg.context = { ...(cfg.context || {}), fileName: [...names] };
|
|
105
|
+
return [{ path: ".gemini/settings.json", content: JSON.stringify(cfg, null, 2) + "\n" }];
|
|
106
|
+
},
|
|
107
|
+
mergeFrom: ".gemini/settings.json",
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
opencode: {
|
|
111
|
+
label: "OpenCode",
|
|
112
|
+
merge: (src, existing) => {
|
|
113
|
+
const cfg = existing ? JSON.parse(existing) : { $schema: "https://opencode.ai/config.json" };
|
|
114
|
+
const want = scoped(src.rules).map((r) => `.agent-os/rules/${r.name}.md`);
|
|
115
|
+
cfg.instructions = [...new Set([...(cfg.instructions || []), ...want])];
|
|
116
|
+
return [{ path: "opencode.json", content: JSON.stringify(cfg, null, 2) + "\n" }];
|
|
117
|
+
},
|
|
118
|
+
mergeFrom: "opencode.json",
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
kilo: {
|
|
122
|
+
label: "Kilo",
|
|
123
|
+
merge: (src, existing) => {
|
|
124
|
+
const cfg = existing ? JSON.parse(existing) : { $schema: "https://app.kilo.ai/config.json" };
|
|
125
|
+
const want = scoped(src.rules).map((r) => `.agent-os/rules/${r.name}.md`);
|
|
126
|
+
cfg.instructions = [...new Set([...(cfg.instructions || []), ...want])];
|
|
127
|
+
return [{ path: "kilo.json", content: JSON.stringify(cfg, null, 2) + "\n" }];
|
|
128
|
+
},
|
|
129
|
+
mergeFrom: "kilo.json",
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Where each tool looks for skills.
|
|
135
|
+
*
|
|
136
|
+
* `.agents/skills/` is the one genuinely shared path in this whole landscape —
|
|
137
|
+
* Antigravity treats it as primary, Gemini CLI as its highest-precedence alias,
|
|
138
|
+
* and Cursor and Windsurf both read it. Four vendors, one directory, identical
|
|
139
|
+
* `name`/`description` frontmatter. So it gets written once, not four times.
|
|
140
|
+
*
|
|
141
|
+
* OpenCode and Kilo have commands rather than skills — a different concept with
|
|
142
|
+
* different invocation semantics — so they are deliberately absent.
|
|
143
|
+
*/
|
|
144
|
+
export const SKILL_DIRS = {
|
|
145
|
+
antigravity: ".agents/skills",
|
|
146
|
+
"gemini-cli": ".agents/skills",
|
|
147
|
+
cursor: ".agents/skills",
|
|
148
|
+
windsurf: ".agents/skills",
|
|
149
|
+
"claude-code": ".claude/skills",
|
|
150
|
+
cline: ".cline/skills",
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** Always written, for every project, regardless of which tools are present. */
|
|
154
|
+
export const UNIVERSAL = {
|
|
155
|
+
label: "AGENTS.md",
|
|
156
|
+
files: (src) => [{ path: "AGENTS.md", content: agentsMd(src) }],
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export function compile(src, targetIds, readExisting) {
|
|
160
|
+
const out = [];
|
|
161
|
+
out.push(...UNIVERSAL.files(src).map((f) => ({ ...f, target: "universal" })));
|
|
162
|
+
|
|
163
|
+
// Skills — deduped by destination, so `.agents/skills/` is emitted once even
|
|
164
|
+
// when four tools that read it are all enabled.
|
|
165
|
+
const dests = new Map();
|
|
166
|
+
for (const id of targetIds) {
|
|
167
|
+
const d = SKILL_DIRS[id];
|
|
168
|
+
if (!d) continue;
|
|
169
|
+
if (!dests.has(d)) dests.set(d, []);
|
|
170
|
+
dests.get(d).push(id);
|
|
171
|
+
}
|
|
172
|
+
for (const [dest, ids] of dests)
|
|
173
|
+
for (const skill of src.skills || [])
|
|
174
|
+
for (const f of skill.files)
|
|
175
|
+
out.push({ path: `${dest}/${skill.name}/${f.rel}`, content: f.content, target: `skills:${ids.join("+")}` });
|
|
176
|
+
|
|
177
|
+
for (const id of targetIds) {
|
|
178
|
+
const t = TARGETS[id];
|
|
179
|
+
if (!t) continue;
|
|
180
|
+
const files = t.merge
|
|
181
|
+
? t.merge(src, readExisting(t.mergeFrom))
|
|
182
|
+
: t.files(src);
|
|
183
|
+
out.push(...files.map((f) => ({ ...f, target: id })));
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|