@sayansr26/agent-os 0.5.3 → 0.6.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/src/plugin.mjs CHANGED
@@ -1,23 +1,37 @@
1
1
  /**
2
- * Install the Claude Code half of agent-os during `init`.
2
+ * Install or update the Claude Code half of agent-os during `init`.
3
3
  *
4
4
  * The CLI compiles rules for every tool. Claude Code additionally gets the
5
- * agents, skills, per-agent memory and session hook, which ship as a plugin —
6
- * so `init` should install it rather than printing two slash commands and
7
- * hoping. `claude plugin ...` with `--yes` is the documented automation path.
5
+ * agents, skills, per-agent memory and hooks, which ship as a plugin. `init`
6
+ * brings that plugin to the latest version whatever state it finds:
8
7
  *
9
- * Everything here is best effort and reversible: if the `claude` CLI is not on
10
- * PATH, or a command fails, `init` reports it and carries on. The rules are the
11
- * part that must not depend on this.
8
+ * not installed marketplace add + install, at project scope
9
+ * installed, disabled enable
10
+ * installed refresh the marketplace, then update
11
+ *
12
+ * An installed plugin is never left behind: Claude Code pins the cached
13
+ * version, so a project installed once stays on that version until something
14
+ * updates it — which is how a project ended up running 0.4.1 hooks.
15
+ *
16
+ * Every command runs with the project as its working directory, so project
17
+ * scope means *this* project even with --root. Best effort throughout: if the
18
+ * `claude` CLI is missing or a command fails, `init` reports it and carries
19
+ * on. The rules are the part that must not depend on this.
12
20
  */
13
21
  import { spawnSync } from "node:child_process";
22
+ import { realpathSync } from "node:fs";
23
+ import { bundledVersion, cmpVersion } from "../plugins/agent-os/skills/init/scripts/state.mjs";
14
24
 
15
25
  export const MARKETPLACE = "sayansr26/agent-os";
16
26
  export const MARKETPLACE_NAME = "sayan-plugins";
17
27
  export const PLUGIN = "agent-os";
28
+ const ID = `${PLUGIN}@${MARKETPLACE_NAME}`;
18
29
 
19
- const run = (args) =>
20
- spawnSync("claude", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
30
+ const run = (args, cwd) =>
31
+ spawnSync("claude", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000 });
32
+ const json = (r) => { try { return JSON.parse(r.stdout); } catch { return null; } };
33
+ const real = (p) => { try { return realpathSync(p); } catch { return p; } };
34
+ const tail = (r) => (r.stderr || r.stdout || String(r.error || "")).trim().split("\n").slice(-3);
21
35
 
22
36
  /** Is the Claude Code CLI usable from here? */
23
37
  export function claudeAvailable() {
@@ -25,50 +39,77 @@ export function claudeAvailable() {
25
39
  return !r.error && r.status === 0;
26
40
  }
27
41
 
42
+ /** This project's install of the plugin, per `claude plugin list --json`, or null. */
43
+ export function installed(root) {
44
+ const list = json(run(["plugin", "list", "--json"], root));
45
+ if (!Array.isArray(list)) return null;
46
+ const mine = list.filter((p) => p.id === ID &&
47
+ (p.scope === "user" || (p.projectPath && real(p.projectPath) === real(root))));
48
+ return mine.find((p) => p.scope === "project" || p.scope === "local") || mine[0] || null;
49
+ }
50
+
28
51
  /**
29
- * Add the marketplace and install the plugin at project scope, so the setup
30
- * travels with the repository instead of living on one machine.
52
+ * Bring the plugin to the latest version for this project.
53
+ * Returns { ok, reason, from, to, done, hint } with reason one of
54
+ * "installed" | "updated" | "current" | "dry-run" | "no-cli" | "failed".
31
55
  */
32
- export function installPlugin({ dry = false, scope = "project" } = {}) {
33
- const steps = [
34
- ["marketplace", ["plugin", "marketplace", "add", MARKETPLACE, "--scope", scope]],
35
- ["plugin", ["plugin", "install", `${PLUGIN}@${MARKETPLACE_NAME}`, "--scope", scope, "--yes"]],
36
- ];
37
-
56
+ export function ensurePlugin({ root = process.cwd(), dry = false, scope = "project" } = {}) {
38
57
  if (!claudeAvailable())
39
58
  return {
40
- ok: false,
41
- reason: "no-cli",
42
- done: [],
59
+ ok: false, reason: "no-cli", done: [],
43
60
  hint: [
44
- "The `claude` CLI is not on PATH, so the plugin was not installed.",
61
+ "The `claude` CLI is not on PATH, so the plugin was not installed or updated.",
45
62
  "Inside Claude Code, run:",
46
63
  ` /plugin marketplace add ${MARKETPLACE}`,
47
- ` /plugin install ${PLUGIN}@${MARKETPLACE_NAME}`,
64
+ ` /plugin install ${ID} (or /plugin update ${ID} if it is already there)`,
48
65
  ],
49
66
  };
50
67
 
68
+ const before = installed(root);
69
+ const markets = json(run(["plugin", "marketplace", "list", "--json"], root));
70
+ const haveMarket = Array.isArray(markets) && markets.some((m) => m.name === MARKETPLACE_NAME);
71
+
72
+ const steps = [];
73
+ steps.push(haveMarket
74
+ ? ["plugin", "marketplace", "update", MARKETPLACE_NAME]
75
+ : ["plugin", "marketplace", "add", MARKETPLACE, "--scope", scope]);
76
+ if (!before) steps.push(["plugin", "install", ID, "--scope", scope, "--yes"]);
77
+ else {
78
+ if (before.enabled === false) steps.push(["plugin", "enable", ID, "--scope", before.scope]);
79
+ steps.push(["plugin", "update", ID, "--scope", before.scope, "--yes"]);
80
+ }
81
+
51
82
  if (dry)
52
- return { ok: true, reason: "dry-run", done: steps.map(([, a]) => `claude ${a.join(" ")}`), hint: [] };
83
+ return { ok: true, reason: "dry-run", from: before?.version || null, done: steps.map((a) => `claude ${a.join(" ")}`), hint: [] };
53
84
 
54
85
  const done = [];
55
- for (const [what, args] of steps) {
56
- const r = run(args);
86
+ for (const args of steps) {
87
+ const r = run(args, root);
57
88
  if (r.status !== 0) {
58
- const err = (r.stderr || r.stdout || "").trim().split("\n").slice(-3).join("\n");
59
89
  return {
60
- ok: false,
61
- reason: what,
62
- done,
90
+ ok: false, reason: "failed", from: before?.version || null, done,
63
91
  hint: [
64
92
  `\`claude ${args.join(" ")}\` failed:`,
65
- ...err.split("\n").map((l) => ` ${l}`),
66
- "The rules above were still written. Install the plugin by hand inside",
67
- `Claude Code: /plugin install ${PLUGIN}@${MARKETPLACE_NAME}`,
93
+ ...tail(r).map((l) => ` ${l}`),
94
+ "The rules and settings above were still written. Inside Claude Code:",
95
+ before ? ` /plugin update ${ID}, then /reload-plugins` : ` /plugin install ${ID}`,
68
96
  ],
69
97
  };
70
98
  }
71
99
  done.push(`claude ${args.join(" ")}`);
72
100
  }
73
- return { ok: true, reason: "installed", done, hint: [] };
101
+
102
+ const after = installed(root);
103
+ const to = after?.version || null;
104
+ const from = before?.version || null;
105
+ const want = bundledVersion();
106
+ const hint = [];
107
+ // The marketplace serves what is pushed to GitHub; this package may be newer.
108
+ if (to && want && cmpVersion(to, want) < 0)
109
+ hint.push(`Marketplace still serves ${to}; this agent-os ships ${want}. It updates once ${MARKETPLACE} is pushed.`);
110
+ return {
111
+ ok: true,
112
+ reason: !before ? "installed" : from !== to ? "updated" : "current",
113
+ from, to, done, hint,
114
+ };
74
115
  }
package/src/selftest.mjs CHANGED
@@ -8,6 +8,12 @@ import { spawnSync } from "node:child_process";
8
8
  import { fileURLToPath } from "node:url";
9
9
 
10
10
  const PKG = join(fileURLToPath(new URL(".", import.meta.url)), "..");
11
+ // Every child inherits this, so nothing here can ever touch the real ~/.claude.
12
+ const HOME = mkdtempSync(join(tmpdir(), "agent-os-home-"));
13
+ process.env.CLAUDE_CONFIG_DIR = HOME;
14
+ const PLUGIN = join(PKG, "plugins/agent-os");
15
+ const node = (script, args, opts = {}) =>
16
+ spawnSync(process.execPath, [join(PLUGIN, script), ...args], { encoding: "utf8", ...opts });
11
17
  const cli = (args, cwd) => spawnSync(process.execPath, [join(PKG, "bin/agent-os.mjs"), ...args, "--root", cwd], { encoding: "utf8" });
12
18
  let fail = 0;
13
19
  const ok = (c, m) => { console.log(` ${c ? "ok " : "FAIL"} ${m}`); if (!c) fail++; };
@@ -85,13 +91,14 @@ try {
85
91
  ok(existsSync(join(r2, ".agent-os/rules/theming.md")), "existing .claude/rules/ are adopted");
86
92
  ok(!existsSync(join(r2, ".agent-os/rules/example.md")), "no example.md when real rules were adopted");
87
93
  ok(!existsSync(join(r2, ".claude/rules/example.md")), "no example.md compiled into the project");
88
- ok(!i.stdout.includes("Claude Code plugin"), "--no-plugin skips the plugin install");
94
+ ok(!i.stdout.includes("--no-plugin skips this"), "--no-plugin skips the plugin install");
89
95
 
90
96
  // init offers the plugin when not told otherwise; --dry-run proves the
91
97
  // commands without running them against the machine's real config.
92
98
  const r4 = mkdtempSync(join(tmpdir(), "agent-os-plug-"));
93
99
  const pi = cli(["init", "--dry-run"], r4);
94
- ok(pi.stdout.includes("Claude Code plugin"), "init sets up the Claude Code plugin by default");
100
+ ok(pi.stdout.includes("--no-plugin skips this"), "init sets up the Claude Code plugin by default");
101
+ ok(pi.stdout.includes("SETUP FRESH"), "init on an empty project reports a fresh setup");
95
102
  ok(/marketplace add sayansr26\/agent-os|plugin marketplace add|not on PATH/.test(pi.stdout),
96
103
  "init names the marketplace step or says why it could not run it");
97
104
  rmSync(r4, { recursive: true, force: true });
@@ -129,6 +136,20 @@ try {
129
136
  ok(st.env.CLAUDE_CODE_ENABLE_TODO_TOOLS === "1", "todo tools enabled in project settings");
130
137
  ok(st.env.FOO === "bar", "existing env keys preserved");
131
138
  ok(st.permissions?.deny?.[0] === "Bash(git push *)", "existing permissions preserved");
139
+ ok(st.permissions.deny.length === 42, "git write protection merged into project settings, no duplicate push rule");
140
+ ok(!existsSync(join(HOME, "settings.json")), "without --global (and no terminal) ~/.claude is left alone");
141
+ ok(!existsSync(join(r5, ".claude/settings.json.agent-os.bak")), "no backup litter inside the project");
142
+
143
+ const again = cli(["init", "--no-plugin", "--global"], r5).stdout;
144
+ ok(again.includes("SETUP REPAIR") && again.includes("MISSING ~/.claude/settings.json git write protection"),
145
+ "init on a set-up project reports a repair and names what is missing");
146
+ ok(again.includes("kept .agent-os/"), "repair keeps the existing source instead of re-scaffolding");
147
+ const us = JSON.parse(readFileSync(join(HOME, "settings.json"), "utf8"));
148
+ ok(us.permissions.deny.includes("Bash(git commit *)") && us.env.CLAUDE_CODE_ENABLE_TODO_TOOLS === "1",
149
+ "--global writes git protection and task tools to ~/.claude/settings.json");
150
+ ok(readFileSync(join(HOME, "CLAUDE.md"), "utf8").includes("TaskCreate"), "--global adds the task rule to ~/.claude/CLAUDE.md");
151
+ cli(["init", "--no-plugin", "--global"], r5);
152
+ ok(JSON.parse(readFileSync(join(HOME, "settings.json"), "utf8")).permissions.deny.length === 42, "second --global run adds nothing");
132
153
 
133
154
  const cm = readFileSync(join(r5, "CLAUDE.md"), "utf8");
134
155
  ok(cm.includes("TaskCreate / TaskUpdate"), "task-tracking rule added to CLAUDE.md");
@@ -155,13 +176,51 @@ try {
155
176
  rmSync(r6, { recursive: true, force: true });
156
177
  }
157
178
 
179
+ console.log("\n plugin hooks");
180
+ {
181
+ const r7 = mkdtempSync(join(tmpdir(), "agent-os-hooks-"));
182
+ const mem = join(r7, ".claude/agent-memory/agent-os-feature-cartographer");
183
+ mkdirSync(mem, { recursive: true });
184
+ for (const f of ["billing", "orders", "search"]) mkdirSync(join(r7, "src/features", f), { recursive: true });
185
+ writeFileSync(join(mem, "_architecture.md"), "# Architecture\n");
186
+ writeFileSync(join(mem, "billing.md"), "---\nmapped: 2026-01-01\nentry: src/features/billing/index.ts\n---\n# billing\n");
187
+ writeFileSync(join(mem, "MEMORY.md"), "- billing — src/features/billing — mapped 2026-01-01\n");
188
+ mkdirSync(join(r7, ".agent-os/rules"), { recursive: true });
189
+ writeFileSync(join(r7, ".agent-os/rules/api.md"), '---\npaths:\n - "src/**"\n---\n\nSource body.\n');
190
+ mkdirSync(join(r7, ".claude/rules"), { recursive: true });
191
+ writeFileSync(join(r7, ".claude/rules/api.md"), "---\n---\n\n<!-- agent-os: generated from .agent-os/ — edit the source -->\nEdited in place.\n");
192
+ const env = { ...process.env, CLAUDE_PROJECT_DIR: r7 };
193
+
194
+ const start = node("hooks/session-resume.mjs", [], { cwd: r7, env }).stdout;
195
+ ok(start.includes("agent-os is active") && start.includes("agent-os:feature-cartographer"), "session start announces the agents");
196
+ ok(start.includes("Mapped: 1 of 3 features") && start.includes("billing"), "session start says what is mapped");
197
+
198
+ const run = `selftest-${process.pid}-${Date.now()}`;
199
+ const edit = (file, session = run) => node("hooks/pre-edit.mjs", [], {
200
+ env, input: JSON.stringify({ session_id: session, cwd: r7, tool_input: { file_path: join(r7, file) } }),
201
+ }).stdout;
202
+ const denied = edit(".claude/rules/api.md");
203
+ ok(denied.includes('"deny"') && denied.includes(".agent-os/rules/api.md"), "edit to a generated rule is blocked, source named");
204
+ ok(edit("src/features/orders/a.ts").includes("no cartographer map"), "first edit to an unmapped feature reminds");
205
+ ok(edit("src/features/orders/b.ts") === "", "reminder shows once per feature per session");
206
+ ok(edit("src/features/billing/a.ts") === "", "mapped feature stays silent");
207
+ ok(edit("src/lib/x.ts") === "", "non-feature path stays silent");
208
+
209
+ mkdirSync(join(mem, ".claude/agent-memory"), { recursive: true });
210
+ const audit = node("skills/init/scripts/audit.mjs", [r7]).stdout;
211
+ ok(/api\.md was edited in place/.test(audit), "audit flags a generated rule that drifted from its source");
212
+ ok(audit.includes("folder inside agent memory"), "audit flags a nested folder in agent memory");
213
+ ok(audit.includes("1 of 3 (33%)"), "audit reports map coverage, not just that a folder exists");
214
+ rmSync(r7, { recursive: true, force: true });
215
+ }
216
+
158
217
  console.log("\n merge, not overwrite");
159
218
  writeFileSync(join(root, "opencode.json"), JSON.stringify({ model: "anthropic/x", instructions: ["KEEP.md"] }, null, 2));
160
219
  cli(["sync"], root);
161
220
  const oc = JSON.parse(read("opencode.json"));
162
221
  ok(oc.model === "anthropic/x", "existing keys preserved");
163
222
  ok(oc.instructions.includes("KEEP.md"), "existing instructions preserved");
164
- } finally { rmSync(root, { recursive: true, force: true }); }
223
+ } finally { rmSync(root, { recursive: true, force: true }); rmSync(HOME, { recursive: true, force: true }); }
165
224
 
166
225
  console.log(`\n${fail ? `FAILED (${fail})` : "PASSED"}\n`);
167
226
  process.exit(fail ? 1 : 0);