@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.
@@ -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
+ }
@@ -1,81 +1,12 @@
1
1
  /**
2
- * The two Claude Code settings that are not rules and not a plugin.
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.
3
4
  *
4
- * Both are written at PROJECT scope only — `.claude/settings.json` and the
5
- * project's `CLAUDE.md`. Nothing here touches `~/.claude/`: a project setup
6
- * tool that edits the machine's global config is overreach, and the point is
7
- * that the setup travels with the repository.
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
8
  */
9
- import { existsSync, readFileSync } from "node:fs";
10
- import { join } from "node:path";
11
- import { write } from "./source.mjs";
12
-
13
- export const TODO_ENV = "CLAUDE_CODE_ENABLE_TODO_TOOLS";
14
-
15
- export const TASK_RULE = `- **Always track work with the task tool (TaskCreate / TaskUpdate).** Any request
16
- with more than one step gets a task list before work starts: one task per
17
- deliverable, marked \`in_progress\` when started and \`completed\` only when
18
- verified. Keep it current as scope changes, including work delegated to
19
- subagents, so I can see what is done, running and left at any moment.`;
20
-
21
- /**
22
- * Turn on the todo tools in project settings, preserving every other key.
23
- * Returns "added" | "present" | "conflict" | "invalid".
24
- */
25
- export function ensureTodoEnv(root, { dry = false } = {}) {
26
- const rel = ".claude/settings.json";
27
- const p = join(root, rel);
28
- let settings = {};
29
- if (existsSync(p)) {
30
- try { settings = JSON.parse(readFileSync(p, "utf8")); }
31
- catch { return { status: "invalid", rel }; }
32
- }
33
- const env = settings.env || {};
34
- const current = env[TODO_ENV];
35
-
36
- // "true" and "1" are both truthy to Claude Code; an existing truthy value is
37
- // the user's choice and is left alone rather than normalised.
38
- if (current !== undefined) {
39
- const on = current === "1" || current === "true" || current === true;
40
- return { status: on ? "present" : "conflict", rel, current };
41
- }
42
-
43
- settings.env = { ...env, [TODO_ENV]: "1" };
44
- if (!dry) write(root, rel, JSON.stringify(settings, null, 2) + "\n");
45
- return { status: "added", rel };
46
- }
47
-
48
- /**
49
- * Put the task-tracking rule in CLAUDE.md if it is not already there.
50
- * Never creates CLAUDE.md — that file is the project's own always-loaded
51
- * context, and inventing one from a CLI is how a project ends up with a stub
52
- * nobody owns. Returns "added" | "present" | "no-file".
53
- */
54
- export function ensureTaskRule(root, { dry = false } = {}) {
55
- const rel = "CLAUDE.md";
56
- const p = join(root, rel);
57
- if (!existsSync(p)) return { status: "no-file", rel };
58
-
59
- const text = readFileSync(p, "utf8");
60
- if (/TaskCreate/.test(text)) return { status: "present", rel };
61
-
62
- // Prefer the operator-preferences section if the project keeps one, so the
63
- // rule lands with the other instructions about how to work rather than
64
- // orphaned at the bottom of the file.
65
- const heading = text.match(/^##\s+Operator preferences\s*$/m);
66
- let next;
67
- if (heading) {
68
- const start = heading.index + heading[0].length;
69
- const rest = text.slice(start);
70
- const nextHeading = rest.search(/^##\s+/m);
71
- const end = nextHeading === -1 ? text.length : start + nextHeading;
72
- const before = text.slice(0, end).replace(/\s*$/, "");
73
- next = `${before}\n\n${TASK_RULE}\n\n${text.slice(end)}`;
74
- } else {
75
- next = `${text.replace(/\s*$/, "")}\n\n## Working agreement\n\n${TASK_RULE}\n`;
76
- }
77
- next = next.replace(/\n{4,}/g, "\n\n\n");
78
-
79
- if (!dry) write(root, rel, next);
80
- return { status: "added", rel, section: heading ? "Operator preferences" : "Working agreement" };
81
- }
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,8 +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";
9
- import { ensureTodoEnv, ensureTaskRule, TODO_ENV } from "./claude-setup.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";
10
12
  import { compile, TARGETS } from "./targets.mjs";
11
13
 
12
14
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
@@ -15,12 +17,14 @@ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
15
17
  const USAGE = `
16
18
  ${bold("agent-os")} — one source of truth for AI coding agent config
17
19
 
18
- 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
19
21
  agent-os sync recompile after editing .agent-os/
20
22
  agent-os check verify nothing drifted (exit 1 if it has) — for CI
21
23
  agent-os detect list which tools this project is set up for
22
24
  agent-os audit inspect the project's context layer and report findings
23
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)
24
28
 
25
29
  Without a global install, prefix any of these with
26
30
  ${dim("npx @sayansr26/agent-os")}
@@ -29,7 +33,9 @@ Options
29
33
  --root <dir> project directory (default: cwd)
30
34
  --dry-run print what would change, write nothing
31
35
  --force overwrite files agent-os did not generate (it refuses by default)
32
- --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
33
39
  `;
34
40
 
35
41
  function scaffold(root, found) {
@@ -116,6 +122,13 @@ export async function main(argv) {
116
122
  return;
117
123
  }
118
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
+
119
132
  if (cmd === "memory") {
120
133
  runScript("plugins/agent-os/skills/memory/scripts/memory.mjs", argv.includes("--stale") ? [root, "--stale"] : [root]);
121
134
  return;
@@ -123,14 +136,28 @@ export async function main(argv) {
123
136
 
124
137
  let adopted = new Set();
125
138
 
139
+ let before = null;
126
140
  if (cmd === "init") {
127
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("");
128
152
  console.log(summarise(found));
153
+ const hadSource = existsSync(join(root, DIR));
129
154
  const { present, taken } = scaffold(root, found);
130
155
  adopted = taken.paths;
131
156
  if (taken.from.length) {
132
157
  console.log(`\n adopted into ${DIR}/ ${dim("— your existing files are now the source")}`);
133
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")}`);
134
161
  } else {
135
162
  console.log(`\n created ${DIR}/ ${dim("(config.json, AGENTS.md, rules/example.md)")}`);
136
163
  }
@@ -214,37 +241,53 @@ Pick one:
214
241
  if (cmd === "init" && targets.includes("claude-code")) {
215
242
  console.log(`\n${bold("Claude Code setup")}\n`);
216
243
 
217
- const e = ensureTodoEnv(root, { dry });
218
- const eMsg = {
219
- added: ` ${e.rel} ${dim(`env.${TODO_ENV} = "1"`)}`,
220
- present: ` ${e.rel} ${dim(`env.${TODO_ENV} already set`)}`,
221
- conflict: ` ${e.rel} ${dim(`env.${TODO_ENV} is "${e.current}" — left as you set it`)}`,
222
- invalid: ` ${e.rel} ${dim("is not valid JSON — left alone, fix it and re-run")}`,
223
- }[e.status];
224
- console.log(eMsg);
225
-
226
- const t = ensureTaskRule(root, { dry });
227
- const tMsg = {
228
- added: ` ${t.rel} ${dim(`task-tracking rule added under ${t.section}`)}`,
229
- present: ` ${t.rel} ${dim("task-tracking rule already there")}`,
230
- "no-file": ` ${t.rel} ${dim("absent — run /agent-os:init inside Claude Code to build it")}`,
231
- }[t.status];
232
- console.log(tMsg);
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
+ }
233
265
  }
234
266
 
235
267
  if (cmd === "init" && targets.includes("claude-code") && !argv.includes("--no-plugin")) {
236
268
  console.log(`\n${bold("Claude Code plugin")} ${dim(`${PLUGIN}@${MARKETPLACE_NAME}`)}\n`);
237
- const r = installPlugin({ dry });
269
+ const r = ensurePlugin({ root, dry });
238
270
  for (const line of r.done) console.log(` ${dim(line)}`);
239
- if (r.ok && r.reason === "installed") {
240
- console.log(`\n installed at project scope ${dim("— .claude/settings.json, so it travels with the repo")}`);
241
- console.log(` ${dim("restart Claude Code, or /reload-plugins, then run /agent-os:init")}`);
242
- } else if (!r.ok) {
243
- console.log("");
244
- for (const line of r.hint) console.log(` ${line}`);
245
- }
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")}`);
246
280
  console.log(`\n ${dim("--no-plugin skips this")}`);
247
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
+ }
248
291
  console.log("");
249
292
  return;
250
293
  }