@sayansr26/agent-os 0.5.2 → 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.
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * agent-os settings — write the permission and task-tool setup, not describe it.
4
+ *
5
+ * Three things, at project scope, user scope, or both:
6
+ *
7
+ * settings.json permissions.deny += the git write-protection rules
8
+ * env.CLAUDE_CODE_ENABLE_TODO_TOOLS = "1"
9
+ * CLAUDE.md the task-tracking rule
10
+ *
11
+ * Merges, never replaces: existing keys, allow rules, deny rules and a value
12
+ * the user set for the env flag all survive. Idempotent. Before changing a
13
+ * file under ~/.claude it copies it to `<file>.agent-os.bak`; project files
14
+ * are left to git, so a backup does not show up as an untracked file.
15
+ *
16
+ * Previews by default. `--apply` writes. The preview is what /agent-os:init
17
+ * shows the user before asking once; the CLI's `init` asks the same question.
18
+ *
19
+ * Usage: node settings.mjs [--root <dir>] [--scope project|user|both] [--apply]
20
+ */
21
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { dirname, join } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ export const TODO_ENV = "CLAUDE_CODE_ENABLE_TODO_TOOLS";
27
+
28
+ export const TASK_RULE = `- **Always track work with the task tool (TaskCreate / TaskUpdate).** Any request
29
+ with more than one step gets a task list before work starts: one task per
30
+ deliverable, marked \`in_progress\` when started and \`completed\` only when
31
+ verified. Keep it current as scope changes, including work delegated to
32
+ subagents, so I can see what is done, running and left at any moment.`;
33
+
34
+ /**
35
+ * Every git command that changes the repository. Read-only inspection
36
+ * (status, log, diff, show, blame, …) stays available. The first five close
37
+ * the flag forms that would otherwise walk past a per-subcommand rule —
38
+ * references/git-permissions.md explains each judgment call.
39
+ */
40
+ export const GIT_DENY = [
41
+ "Bash(git -C*)", "Bash(git -c*)", "Bash(git --git-dir*)", "Bash(git --work-tree*)", "Bash(git --exec-path*)",
42
+ "Bash(git add *)", "Bash(git am *)", "Bash(git apply *)", "Bash(git bisect *)", "Bash(git branch *)",
43
+ "Bash(git checkout *)", "Bash(git cherry-pick *)", "Bash(git clean *)", "Bash(git clone *)", "Bash(git commit *)",
44
+ "Bash(git config *)", "Bash(git fast-import *)", "Bash(git filter-branch *)", "Bash(git gc *)", "Bash(git init *)",
45
+ "Bash(git merge *)", "Bash(git mv *)", "Bash(git notes *)", "Bash(git prune *)", "Bash(git pull *)",
46
+ "Bash(git push *)", "Bash(git rebase *)", "Bash(git reflog *)", "Bash(git remote *)", "Bash(git repack *)",
47
+ "Bash(git replace *)", "Bash(git reset *)", "Bash(git restore *)", "Bash(git revert *)", "Bash(git rm *)",
48
+ "Bash(git stash *)", "Bash(git submodule *)", "Bash(git switch *)", "Bash(git symbolic-ref *)", "Bash(git tag *)",
49
+ "Bash(git update-ref *)", "Bash(git worktree *)",
50
+ ];
51
+
52
+ export const userDir = () => process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
53
+
54
+ export function paths(scope, root) {
55
+ return scope === "user"
56
+ ? { settings: join(userDir(), "settings.json"), claudeMd: join(userDir(), "CLAUDE.md"), label: "USER (~/.claude)" }
57
+ : { settings: join(root, ".claude/settings.json"), claudeMd: join(root, "CLAUDE.md"), label: "PROJECT" };
58
+ }
59
+
60
+ const put = (p, text, keep) => {
61
+ if (keep && existsSync(p)) copyFileSync(p, `${p}.agent-os.bak`);
62
+ mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, text); };
63
+
64
+ // "Bash(git commit *)" and "Bash(git commit:*)" are the same rule to Claude Code.
65
+ const norm = (r) => r.replace(/:\*\)$/, " *)").replace(/\s+/g, " ");
66
+
67
+ /**
68
+ * Merge the deny rules and the env flag into one settings.json.
69
+ * Returns { status: "changed"|"unchanged"|"invalid", file, denyAdded, env, current }
70
+ * where env is "added" | "present" | "conflict".
71
+ */
72
+ export function mergeSettings(file, { dry = false, deny = GIT_DENY, todo = true, keep = false } = {}) {
73
+ let settings = {};
74
+ if (existsSync(file)) {
75
+ try { settings = JSON.parse(readFileSync(file, "utf8")); }
76
+ catch { return { status: "invalid", file, denyAdded: [], env: null }; }
77
+ }
78
+ const perms = settings.permissions || {};
79
+ const have = new Set((perms.deny || []).map(norm));
80
+ const denyAdded = deny.filter((r) => !have.has(norm(r)));
81
+
82
+ let env = null, current;
83
+ if (todo) {
84
+ current = (settings.env || {})[TODO_ENV];
85
+ // "true" and "1" are both truthy to Claude Code; an existing value is the
86
+ // user's choice and is left alone rather than normalised.
87
+ if (current === undefined) env = "added";
88
+ else env = current === "1" || current === "true" || current === true ? "present" : "conflict";
89
+ }
90
+
91
+ if (!denyAdded.length && env !== "added") return { status: "unchanged", file, denyAdded, env, current };
92
+
93
+ if (denyAdded.length) settings.permissions = { ...perms, deny: [...(perms.deny || []), ...denyAdded] };
94
+ if (env === "added") settings.env = { ...(settings.env || {}), [TODO_ENV]: "1" };
95
+ if (!dry) put(file, JSON.stringify(settings, null, 2) + "\n", keep);
96
+ return { status: "changed", file, denyAdded, env, current };
97
+ }
98
+
99
+ /**
100
+ * Put the task-tracking rule in a CLAUDE.md. Returns "added" | "present" | "no-file".
101
+ * A project CLAUDE.md is never created — that is the project's own
102
+ * always-loaded context, and a CLI inventing one leaves a stub nobody owns.
103
+ * The user's ~/.claude/CLAUDE.md is created when `create` is set.
104
+ */
105
+ export function addTaskRule(file, { dry = false, create = false, keep = false } = {}) {
106
+ if (!existsSync(file)) {
107
+ if (!create) return { status: "no-file", file };
108
+ if (!dry) put(file, `# Personal working agreement\n\n${TASK_RULE}\n`);
109
+ return { status: "added", file, section: "new file" };
110
+ }
111
+ const text = readFileSync(file, "utf8");
112
+ if (/TaskCreate/.test(text)) return { status: "present", file };
113
+
114
+ // Prefer an operator-preferences section so the rule lands with the other
115
+ // instructions about how to work rather than orphaned at the bottom.
116
+ const heading = text.match(/^##\s+Operator preferences\s*$/m);
117
+ let next;
118
+ if (heading) {
119
+ const start = heading.index + heading[0].length;
120
+ const rest = text.slice(start);
121
+ const nextHeading = rest.search(/^##\s+/m);
122
+ const end = nextHeading === -1 ? text.length : start + nextHeading;
123
+ next = `${text.slice(0, end).replace(/\s*$/, "")}\n\n${TASK_RULE}\n\n${text.slice(end)}`;
124
+ } else {
125
+ next = `${text.replace(/\s*$/, "")}\n\n## Working agreement\n\n${TASK_RULE}\n`;
126
+ }
127
+ if (!dry) put(file, next.replace(/\n{4,}/g, "\n\n\n"), keep);
128
+ return { status: "added", file, section: heading ? "Operator preferences" : "Working agreement" };
129
+ }
130
+
131
+ /** Everything for one scope. */
132
+ export function applyScope(scope, root, { dry = false } = {}) {
133
+ const p = paths(scope, root);
134
+ return {
135
+ scope,
136
+ label: p.label,
137
+ settings: mergeSettings(p.settings, { dry, keep: scope === "user" }),
138
+ rule: addTaskRule(p.claudeMd, { dry, create: scope === "user", keep: scope === "user" }),
139
+ };
140
+ }
141
+
142
+ export function describe(r, { dry }) {
143
+ const s = r.settings, lines = [];
144
+ const verb = dry ? "would add" : "added";
145
+ lines.push(`${r.label} ${s.file}`);
146
+ if (s.status === "invalid") lines.push(" not valid JSON — left alone; fix it and re-run");
147
+ else {
148
+ lines.push(s.denyAdded.length
149
+ ? ` permissions.deny ${verb} ${s.denyAdded.length} git write rule(s)${s.denyAdded.length < GIT_DENY.length ? ` (${GIT_DENY.length - s.denyAdded.length} already there)` : ""}`
150
+ : ` permissions.deny git write protection already complete`);
151
+ lines.push({
152
+ added: ` env.${TODO_ENV} ${verb} "1"`,
153
+ present: ` env.${TODO_ENV} already on`,
154
+ conflict: ` env.${TODO_ENV} is "${s.current}" — left as set`,
155
+ }[s.env]);
156
+ if (s.status === "changed" && !dry && r.scope === "user" && existsSync(`${s.file}.agent-os.bak`)) lines.push(` backup ${s.file}.agent-os.bak`);
157
+ }
158
+ lines.push({
159
+ added: ` ${r.rule.file} ${dry ? "would add" : "added"} the task-tracking rule (${r.rule.section})`,
160
+ present: ` ${r.rule.file} task-tracking rule already there`,
161
+ "no-file": ` ${r.rule.file} absent — /agent-os:init builds it`,
162
+ }[r.rule.status]);
163
+ return lines;
164
+ }
165
+
166
+ function main(argv) {
167
+ const arg = (k, d) => { const i = argv.indexOf(k); return i === -1 ? d : argv[i + 1]; };
168
+ const root = arg("--root", process.cwd());
169
+ const scope = arg("--scope", "both");
170
+ const dry = !argv.includes("--apply");
171
+ const scopes = scope === "both" ? ["project", "user"] : [scope];
172
+ if (!scopes.every((s) => s === "project" || s === "user")) {
173
+ console.error(`--scope must be project, user or both (got "${scope}")`);
174
+ process.exit(2);
175
+ }
176
+ const out = [`AGENT-OS SETTINGS ${dry ? "preview — nothing written; re-run with --apply" : "applied"}`, ""];
177
+ for (const s of scopes) out.push(...describe(applyScope(s, root, { dry }), { dry }), "");
178
+ if (scopes.includes("user")) out.push("~/.claude applies to every project on this machine.");
179
+ process.stdout.write(out.join("\n") + "\n");
180
+ }
181
+
182
+ const isMain = () => {
183
+ try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); }
184
+ catch { return false; }
185
+ };
186
+ if (process.argv[1] && isMain()) main(process.argv.slice(2));
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * agent-os setup state — is this a fresh setup, a repair, or already healthy?
4
+ *
5
+ * Both `npx agent-os init` and `/agent-os:init` start here, so neither
6
+ * re-scaffolds a project that is set up, and both name exactly what a repair
7
+ * has to touch. Read-only: every check is a file read, and the settings checks
8
+ * are dry runs of what settings.mjs would write.
9
+ *
10
+ * The plugin check reads Claude Code's own records (installed_plugins.json,
11
+ * enabledPlugins, the marketplace cache) instead of calling the `claude` CLI,
12
+ * so it also works from inside a session.
13
+ *
14
+ * Usage: node state.mjs [projectDir]
15
+ */
16
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { addTaskRule, GIT_DENY, mergeSettings, paths, userDir } from "./settings.mjs";
20
+
21
+ export const PLUGIN_ID = "agent-os@sayan-plugins";
22
+ export const MARKETPLACE_NAME = "sayan-plugins";
23
+
24
+ const HERE = dirname(fileURLToPath(import.meta.url));
25
+ const readJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
26
+ const real = (p) => { try { return realpathSync(p); } catch { return p; } };
27
+
28
+ /** -1, 0, 1 for dotted numeric versions; unknown sorts lowest. */
29
+ export function cmpVersion(a, b) {
30
+ const pa = String(a || "0").split(/[.-]/).map((n) => parseInt(n, 10) || 0);
31
+ const pb = String(b || "0").split(/[.-]/).map((n) => parseInt(n, 10) || 0);
32
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
33
+ const d = (pa[i] || 0) - (pb[i] || 0);
34
+ if (d) return d > 0 ? 1 : -1;
35
+ }
36
+ return 0;
37
+ }
38
+
39
+ /** The version of the plugin this script ships in. */
40
+ export const bundledVersion = () => readJson(join(HERE, "../../../.claude-plugin/plugin.json"))?.version || null;
41
+
42
+ /**
43
+ * What Claude Code has installed for this project.
44
+ * { installed, scope, version, enabled, latest } — latest is the version in the
45
+ * local marketplace cache, which is only as new as the last marketplace update.
46
+ */
47
+ export function pluginState(root) {
48
+ const home = userDir();
49
+ const rootReal = real(root);
50
+ const records = readJson(join(home, "plugins/installed_plugins.json"))?.plugins?.[PLUGIN_ID] || [];
51
+ // Project and local installs belong to one directory; user installs to all.
52
+ const mine = records.filter((r) => r.scope === "user" || (r.projectPath && real(r.projectPath) === rootReal));
53
+ const pick = mine.find((r) => r.scope === "project" || r.scope === "local") || mine[0] || null;
54
+
55
+ const enabledIn = [
56
+ join(root, ".claude/settings.local.json"),
57
+ join(root, ".claude/settings.json"),
58
+ join(home, "settings.json"),
59
+ ].map((p) => readJson(p)?.enabledPlugins?.[PLUGIN_ID]).find((v) => v !== undefined);
60
+
61
+ const latest = readJson(join(home, "plugins/marketplaces", MARKETPLACE_NAME, "plugins/agent-os/.claude-plugin/plugin.json"))?.version || null;
62
+ return {
63
+ installed: !!pick,
64
+ scope: pick?.scope || null,
65
+ version: pick?.version || null,
66
+ enabled: pick ? enabledIn !== false : false,
67
+ latest,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Every check, then the verdict:
73
+ * fresh nothing of agent-os is here yet — run the whole setup
74
+ * repair some of it is — fix only the items marked missing
75
+ * healthy all of it is
76
+ * `expect` is the plugin version the caller wants at minimum (the CLI passes
77
+ * the version it bundles).
78
+ */
79
+ export function setupState(root, { expect = bundledVersion() } = {}) {
80
+ const items = [];
81
+ const add = (key, label, ok, fix) => items.push({ key, label, ok, fix });
82
+
83
+ const hasSource = existsSync(join(root, ".agent-os/config.json"));
84
+ const claudeMd = (() => { try { return readFileSync(join(root, "CLAUDE.md"), "utf8"); } catch { return null; } })();
85
+ const hasMemory = existsSync(join(root, ".claude/agent-memory"));
86
+
87
+ add("source", ".agent-os/ source", hasSource, "npx @sayansr26/agent-os init");
88
+ add("claude-md", "CLAUDE.md", claudeMd !== null, "/init in Claude Code, then /agent-os:init");
89
+
90
+ for (const scope of ["project", "user"]) {
91
+ const p = paths(scope, root);
92
+ const s = mergeSettings(p.settings, { dry: true });
93
+ const where = scope === "user" ? "~/.claude/settings.json" : ".claude/settings.json";
94
+ if (s.status === "invalid") { add(`${scope}-settings`, `${where} is valid JSON`, false, `fix ${where} by hand`); continue; }
95
+ const have = GIT_DENY.length - s.denyAdded.length;
96
+ add(`${scope}-git`, `${where} git write protection (${have}/${GIT_DENY.length})`, !s.denyAdded.length, `agent-os settings --scope ${scope} --apply`);
97
+ add(`${scope}-todo`, `${where} task tools`, s.env !== "added", `agent-os settings --scope ${scope} --apply`);
98
+ }
99
+ if (claudeMd !== null) {
100
+ add("task-rule", "CLAUDE.md task-tracking rule", addTaskRule(join(root, "CLAUDE.md"), { dry: true }).status === "present", "agent-os settings --scope project --apply");
101
+ add("agents-section", "CLAUDE.md says when to use each agent", /agent-os:(feature-cartographer|builder|reviewer)/.test(claudeMd), "/agent-os:init (establishing.md, Step 4b)");
102
+ }
103
+
104
+ const pl = pluginState(root);
105
+ const want = [expect, pl.latest].filter(Boolean).sort((a, b) => cmpVersion(b, a))[0] || null;
106
+ add("plugin", "Claude Code plugin installed", pl.installed, "npx @sayansr26/agent-os init");
107
+ if (pl.installed) {
108
+ add("plugin-enabled", "plugin enabled", pl.enabled, `claude plugin enable ${PLUGIN_ID}`);
109
+ add("plugin-version", `plugin version ${pl.version}${want && cmpVersion(pl.version, want) < 0 ? ` (latest ${want})` : ""}`,
110
+ !want || cmpVersion(pl.version, want) >= 0, "npx @sayansr26/agent-os init (or /plugin update, then /reload-plugins)");
111
+ }
112
+
113
+ // Settings alone do not count: deny rules may be the user's own.
114
+ const anything = hasSource || hasMemory || pl.installed || /agent-os/.test(claudeMd || "");
115
+ const missing = items.filter((i) => !i.ok);
116
+ const status = !anything ? "fresh" : missing.length ? "repair" : "healthy";
117
+ return { status, items, missing, plugin: pl };
118
+ }
119
+
120
+ export function describeState(st) {
121
+ const lines = [`SETUP ${st.status.toUpperCase()}${st.status === "repair" ? ` — ${st.missing.length} item(s) to fix` : ""}`];
122
+ for (const i of st.items) lines.push(` ${i.ok ? "ok " : "MISSING"} ${i.label}${i.ok ? "" : ` → ${i.fix}`}`);
123
+ return lines;
124
+ }
125
+
126
+ const isMain = () => {
127
+ try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); }
128
+ catch { return false; }
129
+ };
130
+ if (process.argv[1] && isMain()) {
131
+ const root = process.argv[2] || process.cwd();
132
+ process.stdout.write(describeState(setupState(root)).join("\n") + "\n");
133
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The Claude Code settings that are not rules and not the plugin: git write
3
+ * protection, the todo tools flag, and the task-tracking rule.
4
+ *
5
+ * The logic lives in the plugin (`skills/init/scripts/settings.mjs`) so that
6
+ * `/agent-os:init` inside Claude Code and `agent-os init` in a terminal write
7
+ * exactly the same thing. This module is the CLI's door to it.
8
+ */
9
+ export {
10
+ TODO_ENV, TASK_RULE, GIT_DENY, userDir, paths,
11
+ mergeSettings, addTaskRule, applyScope, describe,
12
+ } from "../plugins/agent-os/skills/init/scripts/settings.mjs";
package/src/cli.mjs CHANGED
@@ -5,7 +5,10 @@ import { join } from "node:path";
5
5
  import { detect, summarise, TOOLS } from "./detect.mjs";
6
6
  import { load, write, readIfExists, matches, BANNER, DIR } from "./source.mjs";
7
7
  import { adopt } from "./adopt.mjs";
8
- import { installPlugin, MARKETPLACE, MARKETPLACE_NAME, PLUGIN } from "./plugin.mjs";
8
+ import { ensurePlugin, MARKETPLACE_NAME, PLUGIN } from "./plugin.mjs";
9
+ import { setupState, describeState } from "../plugins/agent-os/skills/init/scripts/state.mjs";
10
+ import { applyScope, describe } from "./claude-setup.mjs";
11
+ import { createInterface } from "node:readline/promises";
9
12
  import { compile, TARGETS } from "./targets.mjs";
10
13
 
11
14
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
@@ -14,12 +17,14 @@ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
14
17
  const USAGE = `
15
18
  ${bold("agent-os")} — one source of truth for AI coding agent config
16
19
 
17
- agent-os init detect your tools, create .agent-os/, compile
20
+ agent-os init set up a fresh project, or repair and update an existing one
18
21
  agent-os sync recompile after editing .agent-os/
19
22
  agent-os check verify nothing drifted (exit 1 if it has) — for CI
20
23
  agent-os detect list which tools this project is set up for
21
24
  agent-os audit inspect the project's context layer and report findings
22
25
  agent-os memory inspect and health-check every memory store
26
+ agent-os settings preview git write protection + task tools for Claude Code
27
+ (--scope project|user|both, --apply to write)
23
28
 
24
29
  Without a global install, prefix any of these with
25
30
  ${dim("npx @sayansr26/agent-os")}
@@ -28,7 +33,9 @@ Options
28
33
  --root <dir> project directory (default: cwd)
29
34
  --dry-run print what would change, write nothing
30
35
  --force overwrite files agent-os did not generate (it refuses by default)
31
- --no-plugin skip installing the Claude Code plugin during init
36
+ --no-plugin skip installing or updating the Claude Code plugin during init
37
+ --global init: also write ~/.claude settings without asking
38
+ --no-global init: leave ~/.claude alone
32
39
  `;
33
40
 
34
41
  function scaffold(root, found) {
@@ -115,6 +122,13 @@ export async function main(argv) {
115
122
  return;
116
123
  }
117
124
 
125
+ if (cmd === "settings") {
126
+ const i = argv.indexOf("--scope");
127
+ runScript("plugins/agent-os/skills/init/scripts/settings.mjs",
128
+ ["--root", root, "--scope", i === -1 ? "both" : argv[i + 1], ...(argv.includes("--apply") ? ["--apply"] : [])]);
129
+ return;
130
+ }
131
+
118
132
  if (cmd === "memory") {
119
133
  runScript("plugins/agent-os/skills/memory/scripts/memory.mjs", argv.includes("--stale") ? [root, "--stale"] : [root]);
120
134
  return;
@@ -122,14 +136,28 @@ export async function main(argv) {
122
136
 
123
137
  let adopted = new Set();
124
138
 
139
+ let before = null;
125
140
  if (cmd === "init") {
126
141
  console.log(`\n${bold("agent-os init")} ${root}\n`);
142
+ // Decide fresh vs repair before touching anything, and say which, so a
143
+ // second run on a set-up project reads as a repair rather than a re-init.
144
+ before = setupState(root);
145
+ for (const l of describeState(before)) console.log(` ${l}`);
146
+ console.log({
147
+ fresh: `\n ${dim("Fresh setup: creating everything.")}`,
148
+ repair: `\n ${dim("Existing setup: fixing the items marked MISSING, leaving the rest as it is.")}`,
149
+ healthy: `\n ${dim("Setup is complete: re-syncing and checking the plugin for updates.")}`,
150
+ }[before.status]);
151
+ console.log("");
127
152
  console.log(summarise(found));
153
+ const hadSource = existsSync(join(root, DIR));
128
154
  const { present, taken } = scaffold(root, found);
129
155
  adopted = taken.paths;
130
156
  if (taken.from.length) {
131
157
  console.log(`\n adopted into ${DIR}/ ${dim("— your existing files are now the source")}`);
132
158
  for (const f of taken.from) console.log(` ${f}`);
159
+ } else if (hadSource) {
160
+ console.log(`\n kept ${DIR}/ ${dim("— already the source; nothing re-scaffolded")}`);
133
161
  } else {
134
162
  console.log(`\n created ${DIR}/ ${dim("(config.json, AGENTS.md, rules/example.md)")}`);
135
163
  }
@@ -210,19 +238,56 @@ Pick one:
210
238
  // The rules are done and safe at this point. The Claude Code plugin is the
211
239
  // other half — agents, skills, per-agent memory, the session hook — and it
212
240
  // installs from the same repo. Only on `init`, never on `sync`.
241
+ if (cmd === "init" && targets.includes("claude-code")) {
242
+ console.log(`\n${bold("Claude Code setup")}\n`);
243
+
244
+ // Project scope always: it travels with the repo. User scope protects
245
+ // every other project on the machine too, so it is asked once rather
246
+ // than assumed — unless --global / --no-global already answered.
247
+ for (const l of describe(applyScope("project", root, { dry }), { dry })) console.log(` ${l}`);
248
+
249
+ let global = argv.includes("--global") ? true : argv.includes("--no-global") ? false : null;
250
+ if (global === null && !dry && process.stdin.isTTY && process.stdout.isTTY) {
251
+ console.log("");
252
+ for (const l of describe(applyScope("user", root, { dry: true }), { dry: true })) console.log(` ${l}`);
253
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
254
+ const a = (await rl.question(`\n Apply the same to ~/.claude (every project on this machine)? [Y/n] `)).trim().toLowerCase();
255
+ rl.close();
256
+ global = a === "" || a === "y" || a === "yes";
257
+ }
258
+ if (global) {
259
+ console.log("");
260
+ for (const l of describe(applyScope("user", root, { dry }), { dry })) console.log(` ${l}`);
261
+ } else if (global === null) {
262
+ console.log(`\n ${dim("~/.claude left alone (not a terminal). To protect every project:")}`);
263
+ console.log(` ${dim("npx @sayansr26/agent-os settings --scope user --apply")}`);
264
+ }
265
+ }
266
+
213
267
  if (cmd === "init" && targets.includes("claude-code") && !argv.includes("--no-plugin")) {
214
268
  console.log(`\n${bold("Claude Code plugin")} ${dim(`${PLUGIN}@${MARKETPLACE_NAME}`)}\n`);
215
- const r = installPlugin({ dry });
269
+ const r = ensurePlugin({ root, dry });
216
270
  for (const line of r.done) console.log(` ${dim(line)}`);
217
- if (r.ok && r.reason === "installed") {
218
- console.log(`\n installed at project scope ${dim("— .claude/settings.json, so it travels with the repo")}`);
219
- console.log(` ${dim("restart Claude Code, or /reload-plugins, then run /agent-os:init")}`);
220
- } else if (!r.ok) {
221
- console.log("");
222
- for (const line of r.hint) console.log(` ${line}`);
223
- }
271
+ if (r.ok) console.log({
272
+ installed: `\n installed ${r.to || ""} at project scope ${dim("— .claude/settings.json, so it travels with the repo")}`,
273
+ updated: `\n updated ${r.from} → ${r.to}`,
274
+ current: `\n already the latest (${r.to})`,
275
+ "dry-run": `\n ${dim(r.from ? `installed: ${r.from} — would refresh and update` : "not installed — would install")}`,
276
+ }[r.reason]);
277
+ for (const line of r.hint) console.log(` ${r.ok ? dim(line) : line}`);
278
+ if (r.reason === "installed" || r.reason === "updated")
279
+ console.log(` ${dim("restart Claude Code, or /reload-plugins, to load it")}`);
224
280
  console.log(`\n ${dim("--no-plugin skips this")}`);
225
281
  }
282
+
283
+ if (cmd === "init" && !dry) {
284
+ const after = setupState(root);
285
+ console.log(`\n${bold("Result")} ${before.status} → ${after.status}`);
286
+ if (after.missing.length) {
287
+ console.log(dim(" still to do:"));
288
+ for (const i of after.missing) console.log(` ${i.label} → ${i.fix}`);
289
+ }
290
+ }
226
291
  console.log("");
227
292
  return;
228
293
  }
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
  }