@sayansr26/agent-os 0.5.0 → 0.5.1

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/CHANGELOG.md CHANGED
@@ -40,6 +40,18 @@ among several rather than the whole product.
40
40
  - Documentation is generic throughout; examples are invented.
41
41
 
42
42
  ### Fixed
43
+ - **`init` scaffolded over projects that already had rules.** It wrote a
44
+ placeholder `AGENTS.md` and an `example.md` rule regardless of what was
45
+ there, and the next `sync` compiled the placeholder on top of the project's
46
+ real `AGENTS.md`. `init` now adopts what it finds — the existing `AGENTS.md`
47
+ and the first rules directory it recognises, `.cursor/rules/*.mdc` converted
48
+ back to `paths:` — and seeds `example.md` only when there was nothing to
49
+ adopt. A tool for stopping rule drift must not cause it.
50
+ - **`sync` now refuses to overwrite a file it did not generate.** Generated
51
+ files carry a banner; anything else at a generated path is the user's own
52
+ work. `sync` names those files, leaves them alone and exits non-zero, with
53
+ `--force` as the explicit opt-out. `AGENTS.md` carries the banner too — it
54
+ previously did not, which is why nothing could tell it apart.
43
55
  - `check` reported every skill file as drifted immediately after a `sync`. Skill
44
56
  files are read as buffers so a skill can ship a binary asset, rule files are
45
57
  generated as strings, and the two were compared with `!==`. Comparison is now
package/README.md CHANGED
@@ -76,6 +76,13 @@ Every command below is `npx @sayansr26/agent-os <command>`. Install it once —
76
76
 
77
77
  `--root <dir>` to target another directory, `--dry-run` to preview.
78
78
 
79
+ **On an existing project, `init` adopts rather than scaffolds.** It takes your
80
+ current `AGENTS.md` and the first rules directory it recognises as the source,
81
+ so the first `sync` regenerates what you already had. **`sync` never overwrites
82
+ a file it did not generate** — generated files carry a banner, anything else at
83
+ that path is yours. It names those files, leaves them alone and exits 1; pass
84
+ `--force` if you really mean to replace them.
85
+
79
86
  Generated files carry a banner. Edit `.agent-os/`, run `sync`, never edit the output.
80
87
 
81
88
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayansr26/agent-os",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "One source of truth for AI coding agent config. Write your rules once; compile them to Claude Code, Cursor, Cline, Windsurf, Antigravity, Gemini CLI, OpenCode and Kilo.",
5
5
  "keywords": [
6
6
  "ai",
@@ -46,4 +46,4 @@
46
46
  "test": "node scripts/validate-plugin.mjs && node src/selftest.mjs",
47
47
  "prepublishOnly": "npm test"
48
48
  }
49
- }
49
+ }
package/src/adopt.mjs ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Adopt what a project already has, instead of scaffolding over it.
3
+ *
4
+ * `init` used to write a placeholder `AGENTS.md` and an `example.md` rule into
5
+ * every project, including projects that already had a real AGENTS.md and a
6
+ * directory of real rules. The next `sync` then compiled the placeholder over
7
+ * the real file. A tool whose whole purpose is to stop rule files drifting must
8
+ * not destroy the rules it finds, so `init` now imports them as the source.
9
+ */
10
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
11
+ import { join, basename } from "node:path";
12
+ import { BANNER } from "./source.mjs";
13
+
14
+ const isGenerated = (text) => text.includes(BANNER);
15
+
16
+ const readDirMd = (dir, ext) => {
17
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
18
+ return readdirSync(dir)
19
+ .filter((f) => f.endsWith(ext))
20
+ .map((f) => ({ name: basename(f, ext), text: readFileSync(join(dir, f), "utf8") }))
21
+ .filter((r) => !isGenerated(r.text));
22
+ };
23
+
24
+ /** `.cursor/rules/*.mdc` back into the canonical `paths:` / `always:` shape. */
25
+ function fromCursor({ name, text }) {
26
+ const m = text.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
27
+ if (!m) return { name, text };
28
+ const fields = {};
29
+ for (const line of m[1].split("\n")) {
30
+ const kv = line.match(/^([a-zA-Z_][\w-]*):\s*(.*)$/);
31
+ if (kv) fields[kv[1]] = kv[2].trim();
32
+ }
33
+ const globs = (fields.globs || "").split(",").map((g) => g.trim()).filter(Boolean);
34
+ const always = String(fields.alwaysApply) === "true";
35
+ const fm = ["---"];
36
+ if (fields.description) fm.push(`description: ${fields.description}`);
37
+ if (always || globs.length === 0) fm.push("always: true");
38
+ else { fm.push("paths:"); for (const g of globs) fm.push(` - "${g}"`); }
39
+ fm.push("---", "");
40
+ return { name, text: `${fm.join("\n")}\n${m[2].trim()}\n` };
41
+ }
42
+
43
+ /**
44
+ * What this project already has that should become the source.
45
+ * Returns `{ agents, rules, from, paths }`; `from` is what to tell the user,
46
+ * `paths` the files it consumed — safe for `init` to regenerate in place.
47
+ */
48
+ export function adopt(root) {
49
+ const from = [];
50
+ const paths = new Set();
51
+ let agents = null;
52
+
53
+ const agentsPath = join(root, "AGENTS.md");
54
+ if (existsSync(agentsPath)) {
55
+ const text = readFileSync(agentsPath, "utf8");
56
+ if (!isGenerated(text)) { agents = text; from.push("AGENTS.md"); paths.add("AGENTS.md"); }
57
+ }
58
+
59
+ // In precedence order — the first store that has anything wins, because these
60
+ // are copies of each other in a project that was keeping them in sync by hand.
61
+ const sources = [
62
+ [".claude/rules", ".md", (r) => r],
63
+ [".clinerules", ".md", (r) => r],
64
+ [".agents/rules", ".md", (r) => r],
65
+ [".cursor/rules", ".mdc", fromCursor],
66
+ [".windsurf/rules", ".md", (r) => r],
67
+ ];
68
+
69
+ let rules = [];
70
+ for (const [dir, ext, convert] of sources) {
71
+ const found = readDirMd(join(root, dir), ext);
72
+ if (!found.length) continue;
73
+ rules = found.map(convert);
74
+ from.push(`${dir}/ (${rules.length} rule${rules.length === 1 ? "" : "s"})`);
75
+ for (const r of found) paths.add(`${dir}/${r.name}${ext}`);
76
+ break;
77
+ }
78
+
79
+ return { agents, rules, from, paths };
80
+ }
package/src/cli.mjs CHANGED
@@ -1,9 +1,10 @@
1
- import { readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
1
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync } from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { join } from "node:path";
5
5
  import { detect, summarise, TOOLS } from "./detect.mjs";
6
- import { load, write, readIfExists, matches, DIR } from "./source.mjs";
6
+ import { load, write, readIfExists, matches, BANNER, DIR } from "./source.mjs";
7
+ import { adopt } from "./adopt.mjs";
7
8
  import { compile, TARGETS } from "./targets.mjs";
8
9
 
9
10
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
@@ -25,6 +26,7 @@ ${bold("agent-os")} — one source of truth for AI coding agent config
25
26
  Options
26
27
  --root <dir> project directory (default: cwd)
27
28
  --dry-run print what would change, write nothing
29
+ --force overwrite files agent-os did not generate (it refuses by default)
28
30
  `;
29
31
 
30
32
  function scaffold(root, found) {
@@ -32,6 +34,17 @@ function scaffold(root, found) {
32
34
  mkdirSync(join(root, DIR, "rules"), { recursive: true });
33
35
  if (!existsSync(join(root, DIR, "config.json")))
34
36
  write(root, `${DIR}/config.json`, JSON.stringify({ targets: present.length ? present : ["claude-code"] }, null, 2) + "\n");
37
+
38
+ // Take what the project already has as the source. Writing a placeholder over
39
+ // a real AGENTS.md, then compiling the placeholder back on top of it, is the
40
+ // exact drift this tool exists to prevent.
41
+ const taken = adopt(root);
42
+ if (taken.agents && !existsSync(join(root, DIR, "AGENTS.md")))
43
+ write(root, `${DIR}/AGENTS.md`, taken.agents);
44
+ for (const r of taken.rules)
45
+ if (!existsSync(join(root, DIR, "rules", `${r.name}.md`)))
46
+ write(root, `${DIR}/rules/${r.name}.md`, r.text);
47
+
35
48
  if (!existsSync(join(root, DIR, "AGENTS.md")))
36
49
  write(root, `${DIR}/AGENTS.md`, `# Project instructions
37
50
 
@@ -42,7 +55,11 @@ Keep it short. Anything that only matters for part of the tree belongs in
42
55
  \`.agent-os/rules/\` instead, where it can be scoped to the files it applies to.
43
56
  `);
44
57
  mkdirSync(join(root, DIR, "skills"), { recursive: true });
45
- if (!existsSync(join(root, DIR, "rules", "example.md")))
58
+
59
+ // Only seed the placeholder when there was nothing to adopt. A project with
60
+ // real rules does not need an `example.md` compiled into every tool it uses.
61
+ const anyRule = readdirSync(join(root, DIR, "rules")).some((f) => f.endsWith(".md"));
62
+ if (!anyRule)
46
63
  write(root, `${DIR}/rules/example.md`, `---
47
64
  description: Conventions for the API layer
48
65
  paths:
@@ -55,7 +72,7 @@ A rule with \`paths:\` is loaded only when the agent touches a matching file, in
55
72
  every tool that supports conditional loading. Tools that do not support it get
56
73
  these as a referenced list instead of always-on text.
57
74
  `);
58
- return present;
75
+ return { present, taken };
59
76
  }
60
77
 
61
78
  export async function main(argv) {
@@ -101,11 +118,19 @@ export async function main(argv) {
101
118
  return;
102
119
  }
103
120
 
121
+ let adopted = new Set();
122
+
104
123
  if (cmd === "init") {
105
124
  console.log(`\n${bold("agent-os init")} ${root}\n`);
106
125
  console.log(summarise(found));
107
- const present = scaffold(root, found);
108
- console.log(`\n created ${DIR}/ ${dim("(config.json, AGENTS.md, rules/example.md)")}`);
126
+ const { present, taken } = scaffold(root, found);
127
+ adopted = taken.paths;
128
+ if (taken.from.length) {
129
+ console.log(`\n adopted into ${DIR}/ ${dim("— your existing files are now the source")}`);
130
+ for (const f of taken.from) console.log(` ${f}`);
131
+ } else {
132
+ console.log(`\n created ${DIR}/ ${dim("(config.json, AGENTS.md, rules/example.md)")}`);
133
+ }
109
134
  if (!present.length) console.log(` ${dim("no tools detected — defaulting to claude-code; edit .agent-os/config.json")}`);
110
135
  }
111
136
 
@@ -142,13 +167,42 @@ export async function main(argv) {
142
167
  }
143
168
  return TARGETS[t]?.label || t;
144
169
  };
170
+ // Never overwrite a file this tool did not write. A generated file carries
171
+ // the banner; anything else at that path is the user's own work, and
172
+ // silently compiling over it is worse than doing nothing.
173
+ const force = argv.includes("--force");
174
+ const isOurs = (f) => {
175
+ const cur = readIfExists(root, f.path);
176
+ if (cur === null) return true; // nothing there yet
177
+ if (cur.includes(BANNER)) return true; // we wrote it
178
+ if (TARGETS[f.target]?.merge) return true; // merged in place, nothing lost
179
+ if (adopted.has(f.path)) return true; // init just took this as the source
180
+ if (matches(root, f.path, f.content) === true) return true; // already identical
181
+ return false;
182
+ };
183
+
184
+ const blocked = [];
145
185
  for (const [t, fs_] of Object.entries(byTarget)) {
146
186
  console.log(` ${labelFor(t).padEnd(38)} ${fs_.length} file(s)`);
147
187
  for (const f of fs_) {
188
+ if (!force && !isOurs(f)) { blocked.push(f.path); console.log(` ${f.path} ${dim("SKIPPED — not generated by agent-os")}`); continue; }
148
189
  if (!dry) write(root, f.path, f.content);
149
190
  console.log(` ${f.path}`);
150
191
  }
151
192
  }
193
+
194
+ if (blocked.length) {
195
+ console.log(`\n${bold(`${blocked.length} file(s) left alone`)} because agent-os did not write them:\n`);
196
+ for (const b of blocked) console.log(` ${b}`);
197
+ console.log(`
198
+ Pick one:
199
+ · move the content into ${DIR}/ so it becomes the source, then re-run
200
+ · ${dim("--force")} to overwrite (the current content is lost — commit first)
201
+ `);
202
+ process.exitCode = 1;
203
+ return;
204
+ }
205
+
152
206
  console.log(`\n${dim("Generated files carry a banner. Edit .agent-os/ and re-run sync; never edit them directly.")}\n`);
153
207
  return;
154
208
  }
package/src/selftest.mjs CHANGED
@@ -53,11 +53,56 @@ try {
53
53
 
54
54
  console.log("\n drift");
55
55
  ok(cli(["check"], root).status === 0, "check passes when in sync");
56
- writeFileSync(join(root, ".cursor/rules/api.mdc"), "hand edited\n");
56
+ // Realistic drift: someone edits the body of a generated file and leaves the
57
+ // banner in place. sync owns that file and must put it back.
58
+ writeFileSync(join(root, ".cursor/rules/api.mdc"),
59
+ read(".cursor/rules/api.mdc").replace("Validate at the boundary.", "hand edited"));
57
60
  const d = cli(["check"], root);
58
61
  ok(d.status === 1, "check exits 1 on drift");
59
62
  ok(d.stdout.includes("DRIFTED"), "check names the drifted file");
60
63
  ok(cli(["sync"], root).status === 0 && cli(["check"], root).status === 0, "sync repairs drift");
64
+ // Banner stripped: now indistinguishable from a hand-written file, so sync
65
+ // refuses rather than guessing.
66
+ writeFileSync(join(root, ".cursor/rules/api.mdc"), "mine now\n");
67
+ ok(cli(["sync"], root).status === 1, "sync refuses once the banner is gone");
68
+ ok(read(".cursor/rules/api.mdc") === "mine now\n", "and leaves that file untouched");
69
+ cli(["sync", "--force"], root);
70
+
71
+ console.log("\n never clobbers what it did not write");
72
+ {
73
+ const r2 = mkdtempSync(join(tmpdir(), "agent-os-adopt-"));
74
+ mkdirSync(join(r2, ".claude/rules"), { recursive: true });
75
+ const realAgents = "# Real AGENTS.md\n\nCLAUDE.md is the source of truth.\n";
76
+ writeFileSync(join(r2, "AGENTS.md"), realAgents);
77
+ writeFileSync(join(r2, ".claude/rules/theming.md"),
78
+ '---\ndescription: Theming\npaths:\n - "src/**/*.tsx"\n---\n\nUse the token set.\n');
79
+
80
+ const i = cli(["init"], r2);
81
+ ok(i.status === 0, "init exits 0 on a project that already has rules");
82
+ ok(readFileSync(join(r2, "AGENTS.md"), "utf8") === realAgents ||
83
+ readFileSync(join(r2, ".agent-os/AGENTS.md"), "utf8") === realAgents,
84
+ "existing AGENTS.md becomes the source rather than being replaced");
85
+ ok(existsSync(join(r2, ".agent-os/rules/theming.md")), "existing .claude/rules/ are adopted");
86
+ ok(!existsSync(join(r2, ".agent-os/rules/example.md")), "no example.md when real rules were adopted");
87
+ ok(!existsSync(join(r2, ".claude/rules/example.md")), "no example.md compiled into the project");
88
+
89
+ // A hand-written file at a generated path must survive a sync.
90
+ const r3 = mkdtempSync(join(tmpdir(), "agent-os-guard-"));
91
+ mkdirSync(join(r3, ".agent-os/rules"), { recursive: true });
92
+ writeFileSync(join(r3, ".agent-os/config.json"), JSON.stringify({ targets: ["claude-code"] }));
93
+ writeFileSync(join(r3, ".agent-os/AGENTS.md"), "# Compiled\n");
94
+ writeFileSync(join(r3, ".agent-os/rules/x.md"), '---\npaths:\n - "a/**"\n---\n\nBody.\n');
95
+ const mine = "# Mine, by hand\n";
96
+ writeFileSync(join(r3, "AGENTS.md"), mine);
97
+ const g = cli(["sync"], r3);
98
+ ok(g.status === 1, "sync exits 1 rather than clobbering");
99
+ ok(readFileSync(join(r3, "AGENTS.md"), "utf8") === mine, "hand-written AGENTS.md survives sync");
100
+ ok(g.stdout.includes("SKIPPED"), "sync says which file it left alone");
101
+ const fg = cli(["sync", "--force"], r3);
102
+ ok(fg.status === 0 && readFileSync(join(r3, "AGENTS.md"), "utf8") !== mine, "--force overwrites when asked");
103
+ rmSync(r2, { recursive: true, force: true });
104
+ rmSync(r3, { recursive: true, force: true });
105
+ }
61
106
 
62
107
  console.log("\n merge, not overwrite");
63
108
  writeFileSync(join(root, "opencode.json"), JSON.stringify({ model: "anthropic/x", instructions: ["KEEP.md"] }, null, 2));
package/src/targets.mjs CHANGED
@@ -28,7 +28,9 @@ function agentsMd(src) {
28
28
  s.map((r) => `- \`${r.paths.join("`, `")}\` — ${r.description || r.name}`).join("\n")
29
29
  );
30
30
  }
31
- return parts.filter(Boolean).join("\n\n") + "\n";
31
+ // The banner is what lets `sync` tell its own output from the user's file.
32
+ // Without it AGENTS.md is indistinguishable from hand-written work.
33
+ return mdHeader + parts.filter(Boolean).join("\n\n") + "\n";
32
34
  }
33
35
 
34
36
  export const TARGETS = {