@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.
Files changed (31) hide show
  1. package/.claude-plugin/marketplace.json +27 -0
  2. package/CHANGELOG.md +203 -0
  3. package/LICENSE +21 -0
  4. package/README.md +134 -0
  5. package/bin/agent-os.mjs +6 -0
  6. package/package.json +49 -0
  7. package/plugins/agent-os/.claude-plugin/plugin.json +18 -0
  8. package/plugins/agent-os/agents/architect.md +77 -0
  9. package/plugins/agent-os/agents/builder.md +90 -0
  10. package/plugins/agent-os/agents/documenter.md +87 -0
  11. package/plugins/agent-os/agents/feature-cartographer.md +142 -0
  12. package/plugins/agent-os/agents/orchestrator.md +96 -0
  13. package/plugins/agent-os/agents/reviewer.md +82 -0
  14. package/plugins/agent-os/agents/tester.md +83 -0
  15. package/plugins/agent-os/hooks/hooks.json +17 -0
  16. package/plugins/agent-os/hooks/session-resume.mjs +136 -0
  17. package/plugins/agent-os/skills/init/SKILL.md +134 -0
  18. package/plugins/agent-os/skills/init/references/changing-a-feature.md +104 -0
  19. package/plugins/agent-os/skills/init/references/establishing.md +128 -0
  20. package/plugins/agent-os/skills/init/references/git-permissions.md +118 -0
  21. package/plugins/agent-os/skills/init/references/migrating.md +43 -0
  22. package/plugins/agent-os/skills/init/references/writing-rules.md +50 -0
  23. package/plugins/agent-os/skills/init/scripts/audit.mjs +328 -0
  24. package/plugins/agent-os/skills/map/SKILL.md +67 -0
  25. package/plugins/agent-os/skills/memory/SKILL.md +82 -0
  26. package/plugins/agent-os/skills/memory/scripts/memory.mjs +97 -0
  27. package/src/cli.mjs +157 -0
  28. package/src/detect.mjs +54 -0
  29. package/src/selftest.mjs +71 -0
  30. package/src/source.mjs +102 -0
  31. 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
+ }
@@ -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
+ }
@@ -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
+ }