@rness/cli 0.9.0 → 0.9.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/README.md CHANGED
@@ -93,13 +93,14 @@ decided:
93
93
  written.
94
94
  - With `claude` in `agents`, `sync` registers it in each clone:
95
95
  `.mcp.json` gets `mcpServers.rness`, which runs the pinned copy
96
- (`node ../../.rness/node_modules/@rness/cli/dist/bin/rness.js mcp`), and
97
- `.claude/settings.json` lists `rness` in `enabledMcpjsonServers`. A
96
+ (`node ../../.rness/node_modules/@rness/cli/dist/bin/rness.js mcp`). A
98
97
  team's own `rness` entry is left as it is.
98
+ - Each developer approves the server once, in Claude Code's own dialog.
99
+ rness does not pre-approve it: `enabledMcpjsonServers` in a committed
100
+ settings file would let any change to the `rness` entry of `.mcp.json` run
101
+ unasked. Review such a change like code.
99
102
  - Verified with Claude Code 2.1.284 on 2026-09-29: the server starts from the
100
- repository and its tools answer. Not verified yet: that
101
- `enabledMcpjsonServers` spares the approval prompt in an interactive
102
- session.
103
+ repository and its tools answer.
103
104
  - Another agent can run the same command from a clone; rness writes no
104
105
  configuration for it.
105
106
 
@@ -177,8 +178,9 @@ dependency does this: `util.styleText`, and a banner kept as a constant.
177
178
 
178
179
  The version is written in one place, `.rness/package.json`. `upgrade` pins it
179
180
  there (exact, the rest of the file untouched), installs with the workspace's
180
- package manager, then syncs through the new copy; a failed install restores
181
- the file. Commit `.rness`. Teammates pull, and the next `rness` command in that
181
+ package manager, syncs through the new copy, then commits `.rness`
182
+ (`chore: rness <version>`); a failed install restores the file. Push
183
+ `.rness`. Teammates pull, and the next `rness` command in that
182
184
  workspace installs the new pin itself — frozen, so the working tree stays
183
185
  clean — and carries on: nothing to remember, nothing to run.
184
186
  `RNESS_NO_INSTALL=1` turns that off and restores the warning, for CI, a
@@ -216,8 +218,10 @@ now names; `rness validate` warns while it is behind.
216
218
  scaffold did not change; overlapping lines conflict, and `upgrade` stops
217
219
  before installing — resolve with git (`git checkout --ours <file>` keeps
218
220
  yours, `--theirs` takes the scaffold's), commit, run `upgrade` again.
219
- - `.rness` must be a clean git repository. The merge is staged, not
220
- committed: the next steps end in one commit.
221
+ - `.rness` must be a clean git repository with no merge in progress.
222
+ `upgrade` commits the merge itself, through the repository's hooks. When a
223
+ hook refuses, everything stays staged, what it said is shown, and
224
+ `upgrade` exits 1 with the commit to make.
221
225
  - A workspace with no scaffold commit (made by hand, or older than
222
226
  `create`) is adopted: identical files merge silently, a differing one
223
227
  conflicts once.
@@ -231,13 +235,32 @@ pin merges the scaffold of the copy installed in `.rness`. A global below
231
235
  `npx` form, or the pinned copy from inside `.rness`. A workspace pinned below 0.5.0, whose copy has no such
232
236
  command, starts with the `npx` form.
233
237
 
234
- An upgrade touches `.rness` only: a generated block is current when its hash
235
- is, whatever wrote it, so no `AGENTS.md` of the organization changes unless
236
- its content does.
238
+ An upgrade commits `.rness` only. Its sync rewrites a block only when the
239
+ block's content changed — a block is current when its hash is, whatever
240
+ wrote it — and writes the agent files a release adds. The next steps list,
241
+ per repository, the files to commit there.
237
242
 
238
243
  Exit codes: 0 success, 1 failure, 2 usage — or a refusal without a TTY.
239
244
  `RNESS_DEBUG=1` adds stack traces; `RNESS_NO_DELEGATE=1` skips the delegation.
240
245
 
246
+ ## 0.9.1 — upgrade ends in its commit; the MCP server approved per developer
247
+
248
+ - `rness upgrade` commits `.rness` itself (`chore: rness <version>`), with
249
+ the repository's hooks; a refusal leaves everything staged and exits 1.
250
+ The next steps say what is left: push, and per repository the files the
251
+ sync changed.
252
+ - `upgrade` syncs every time, the Dependabot case included (pin and install
253
+ already at the target). In 0.9.0 it skipped the sync there, so the values
254
+ 0.9.0 added to the agent files were missing and the scaffold's
255
+ pre-commit hook refused the commit.
256
+ - `upgrade` refuses a `.rness` with a merge in progress.
257
+ - `validate` counts a merge in progress: the commit recording a scaffold
258
+ merge no longer warns that the scaffold is not merged.
259
+ - The Claude target no longer writes `enabledMcpjsonServers`: each developer
260
+ approves the MCP server once. After syncing with 0.9.0, remove `rness`
261
+ from `enabledMcpjsonServers` in each `.claude/settings.json` by hand —
262
+ rness never removes a value.
263
+
241
264
  ## 0.9.0 — the context on demand: `rness mcp`
242
265
 
243
266
  - `rness mcp`: a local, read-only MCP server with four tools —
package/dist/bin/rness.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  findDelegate,
7
7
  reportError,
8
8
  run
9
- } from "../chunk-DL72BCPT.js";
9
+ } from "../chunk-N3F6N6GA.js";
10
10
 
11
11
  // src/bin/rness.ts
12
12
  import { pathToFileURL } from "url";
@@ -743,6 +743,21 @@ async function isClean(dir, ignore = []) {
743
743
  dir
744
744
  )).trim() === "";
745
745
  }
746
+ async function changedPaths(dir, paths) {
747
+ if (paths.length === 0) return [];
748
+ const entries = (await git(
749
+ ["status", "--porcelain", "-z", "--untracked-files=all", "--", ...paths],
750
+ dir
751
+ )).split("\0");
752
+ const changed = [];
753
+ for (let i = 0; i < entries.length; i++) {
754
+ const entry = entries[i] ?? "";
755
+ if (entry.length < 4) continue;
756
+ changed.push(entry.slice(3));
757
+ if (entry[0] === "R" || entry[0] === "C") i++;
758
+ }
759
+ return changed;
760
+ }
746
761
  async function pullFastForward(dir, credentials) {
747
762
  await git(["pull", "--ff-only", "--quiet"], dir, credentials);
748
763
  }
@@ -1390,7 +1405,7 @@ function reportError(e) {
1390
1405
  }
1391
1406
 
1392
1407
  // src/version.ts
1393
- var VERSION = "0.9.0";
1408
+ var VERSION = "0.9.1";
1394
1409
 
1395
1410
  // src/core/block.ts
1396
1411
  import { createHash } from "crypto";
@@ -1705,16 +1720,17 @@ var TARGETS = {
1705
1720
  {
1706
1721
  path: ["permissions", "additionalDirectories"],
1707
1722
  contains: "../../.rness"
1708
- },
1709
- // Start the rness MCP server of .mcp.json without asking.
1710
- { path: ["enabledMcpjsonServers"], contains: "rness" }
1723
+ }
1711
1724
  ]
1712
1725
  },
1713
1726
  {
1714
1727
  file: ".mcp.json",
1715
1728
  guarantees: [
1716
1729
  // The pinned copy, by a path relative to the repository — the
1717
- // directory Claude Code starts the server in (spec 0014 §6).
1730
+ // directory Claude Code starts the server in (spec 0014 §6). Not
1731
+ // pre-approved in settings: each developer approves it once, since
1732
+ // a repository approving its own command would run any change to
1733
+ // it unasked (spec 0014 §3, 0.9.1).
1718
1734
  {
1719
1735
  path: ["mcpServers", "rness"],
1720
1736
  value: {
@@ -1856,6 +1872,13 @@ function declared(manifest) {
1856
1872
  (name) => Object.hasOwn(TARGETS, name) ? [TARGETS[name]] : []
1857
1873
  );
1858
1874
  }
1875
+ var BLOCK_FILES = ["AGENTS.md", "CLAUDE.md"];
1876
+ function writtenFiles(manifest) {
1877
+ return [
1878
+ ...BLOCK_FILES,
1879
+ ...declared(manifest).flatMap((t) => t.files.map((f) => f.file))
1880
+ ];
1881
+ }
1859
1882
  async function agentTargets(root, manifest, opts) {
1860
1883
  const outcomes = [];
1861
1884
  for (const { guarantees, file, label } of await filesOf(
@@ -2104,7 +2127,6 @@ async function makeUi(terminal) {
2104
2127
  }
2105
2128
 
2106
2129
  // src/commands/sync.ts
2107
- var MANAGED_FILES = ["AGENTS.md", "CLAUDE.md"];
2108
2130
  function targetsOf(manifest, root, only) {
2109
2131
  const targets = [];
2110
2132
  if (only === void 0)
@@ -2294,13 +2316,7 @@ async function syncCommand(opts, deps = {}) {
2294
2316
  }
2295
2317
  } else if (opts.pull === true && !check) {
2296
2318
  try {
2297
- const written = [
2298
- ...MANAGED_FILES,
2299
- ...(manifest.agents ?? []).flatMap(
2300
- (a) => Object.hasOwn(TARGETS, a) ? TARGETS[a].files.map((f) => f.file) : []
2301
- )
2302
- ];
2303
- if (!await isClean(dir, written)) {
2319
+ if (!await isClean(dir, writtenFiles(manifest))) {
2304
2320
  lines.push(["skipped", `${label} (working tree not clean)`]);
2305
2321
  continue;
2306
2322
  }
@@ -2343,7 +2359,7 @@ async function syncCommand(opts, deps = {}) {
2343
2359
  continue;
2344
2360
  }
2345
2361
  const [agentsLinked, claudeLinked] = await Promise.all(
2346
- MANAGED_FILES.map((f) => isSymlink(join10(t.dir, f)))
2362
+ BLOCK_FILES.map((f) => isSymlink(join10(t.dir, f)))
2347
2363
  );
2348
2364
  const atRoot = t.scope === null;
2349
2365
  if (!atRoot && (agentsLinked === true || claudeLinked === true)) {
@@ -3211,12 +3227,33 @@ function scaffoldMessage(subject, version) {
3211
3227
 
3212
3228
  ${SCAFFOLD_TRAILER}: ${version}`;
3213
3229
  }
3230
+ async function commitUpgrade(rnessDir, message) {
3231
+ const add = await run(rnessDir, ["add", "-A"]);
3232
+ if (add.code !== 0) return { kind: "refused", output: add.stderr.trim() };
3233
+ const staged = await run(rnessDir, ["diff", "--cached", "--quiet"]);
3234
+ if (staged.code === 0 && !await mergeInProgress(rnessDir))
3235
+ return { kind: "nothing" };
3236
+ const commit = await run(rnessDir, ["commit", "-q", "-m", message]);
3237
+ if (commit.code !== 0)
3238
+ return {
3239
+ kind: "refused",
3240
+ output: `${commit.stdout}
3241
+ ${commit.stderr}`.trim()
3242
+ };
3243
+ return { kind: "committed" };
3244
+ }
3245
+ async function mergeInProgress(dir) {
3246
+ const r = await run(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]);
3247
+ return r.code === 0;
3248
+ }
3214
3249
  async function findScaffoldBase(rnessDir) {
3250
+ const merging = await mergeInProgress(rnessDir);
3215
3251
  const log = await run(rnessDir, [
3216
3252
  "log",
3217
3253
  "--topo-order",
3218
3254
  `--format=%H%x1f%P%x1f%s%x1f%(trailers:key=${SCAFFOLD_TRAILER},valueonly,separator=%x2C)%x1e`,
3219
- "HEAD"
3255
+ "HEAD",
3256
+ ...merging ? ["MERGE_HEAD"] : []
3220
3257
  ]);
3221
3258
  if (log.code !== 0) return null;
3222
3259
  const commits = log.stdout.split("").map((r) => r.trim()).filter((r) => r !== "").map((r) => {
@@ -4355,14 +4392,36 @@ async function declaredManager(rnessDir, pm) {
4355
4392
  }
4356
4393
  return `${pm}@${await packageManagerVersion(pm)}`;
4357
4394
  }
4358
- function finish(ui, cwd, rnessDir, label, target) {
4395
+ async function clonesToCommit(root, cwd, manifest) {
4396
+ const files = writtenFiles(manifest);
4397
+ const clones = [];
4398
+ for (const repo of Object.keys(manifest.repos)) {
4399
+ const dir = join21(root, "org", repo);
4400
+ if (!await exists(dir)) continue;
4401
+ try {
4402
+ const paths = await changedPaths(dir, files);
4403
+ if (paths.length > 0)
4404
+ clones.push({ dir: relative5(cwd, dir) || ".", paths });
4405
+ } catch {
4406
+ }
4407
+ }
4408
+ return clones;
4409
+ }
4410
+ function finish(ui, cwd, rnessDir, label, target, ending) {
4359
4411
  const rel = relative5(cwd, rnessDir) || ".";
4360
- const next = [
4361
- `git -C ${rel} add -A && git -C ${rel} commit -m "chore: rness ${target}"`,
4362
- "# teammates: git pull \u2014 the next rness command installs it"
4363
- ];
4412
+ const message = `chore: rness ${target}`;
4413
+ const next = [];
4414
+ if (ending.commit === "committed") next.push(`git -C ${rel} push`);
4415
+ if (ending.commit === "refused")
4416
+ next.push(`git -C ${rel} commit -m "${message}"`);
4417
+ for (const c of ending.clones)
4418
+ next.push(
4419
+ `git -C ${c.dir} add ${c.paths.join(" ")} && git -C ${c.dir} commit -m "${message}"`
4420
+ );
4421
+ if (ending.commit !== "nothing")
4422
+ next.push("# teammates: git pull \u2014 the next rness command installs it");
4364
4423
  if (ui.session) {
4365
- ui.note("Next steps", next);
4424
+ if (next.length > 0) ui.note("Next steps", next);
4366
4425
  ui.outro(`${label} runs @rness/cli ${target}`);
4367
4426
  } else
4368
4427
  process.stdout.write(
@@ -4370,12 +4429,10 @@ function finish(ui, cwd, rnessDir, label, target) {
4370
4429
  "",
4371
4430
  status("upgraded", `${label} to @rness/cli ${target}`),
4372
4431
  "",
4373
- "Next:",
4374
- ...next.map((line2) => ` ${line2}`),
4375
- ""
4432
+ ...next.length > 0 ? ["Next:", ...next.map((line2) => ` ${line2}`), ""] : []
4376
4433
  ].join("\n")
4377
4434
  );
4378
- return 0;
4435
+ return ending.commit === "refused" ? 1 : 0;
4379
4436
  }
4380
4437
  async function upgradeCommand(version, opts, terminal = defaultTerminal, deps = {}) {
4381
4438
  const cwd = opts.cwd ?? process.cwd();
@@ -4424,6 +4481,13 @@ async function upgradeCommand(version, opts, terminal = defaultTerminal, deps =
4424
4481
  `);
4425
4482
  return 1;
4426
4483
  }
4484
+ if (await mergeInProgress(ws.rnessDir)) {
4485
+ process.stderr.write(
4486
+ `${label} has a merge in progress; commit it, or abort it with git -C ${relative5(cwd, ws.rnessDir) || "."} merge --abort, then ${rnessCommand()} upgrade
4487
+ `
4488
+ );
4489
+ return 1;
4490
+ }
4427
4491
  if (!await isClean(ws.rnessDir)) {
4428
4492
  process.stderr.write(
4429
4493
  `${label} has uncommitted changes; commit or stash them, then ${rnessCommand()} upgrade
@@ -4532,48 +4596,71 @@ async function upgradeCommand(version, opts, terminal = defaultTerminal, deps =
4532
4596
  const moved = await readPin(ws.rnessDir);
4533
4597
  if (moved !== null && moved.spec !== target)
4534
4598
  await writePin(ws.rnessDir, moved.spec, target);
4535
- if (pin.spec === target && installed === target)
4536
- return finish(ui, cwd, ws.rnessDir, label, target);
4537
- try {
4538
- await ui.step(
4539
- { doing: `installing dependencies with ${pm}` },
4540
- () => installDependencies(pm, ws.rnessDir),
4541
- () => ["installed", `dependencies with ${pm}`]
4542
- );
4543
- } catch (e) {
4544
- const message = e instanceof Error ? e.message : String(e);
4545
- ui.error(message);
4546
- if (!merged) {
4547
- for (const [file, text] of restore) await writeFileAtomic(file, text);
4548
- ui.hint(
4549
- `restored ${relative5(cwd, packageFile) || packageFile}`,
4550
- "stderr"
4599
+ const mustInstall = !(pin.spec === target && installed === target);
4600
+ if (mustInstall) {
4601
+ try {
4602
+ await ui.step(
4603
+ { doing: `installing dependencies with ${pm}` },
4604
+ () => installDependencies(pm, ws.rnessDir),
4605
+ () => ["installed", `dependencies with ${pm}`]
4551
4606
  );
4607
+ } catch (e) {
4608
+ const message2 = e instanceof Error ? e.message : String(e);
4609
+ ui.error(message2);
4610
+ if (!merged) {
4611
+ for (const [file, text] of restore) await writeFileAtomic(file, text);
4612
+ ui.hint(
4613
+ `restored ${relative5(cwd, packageFile) || packageFile}`,
4614
+ "stderr"
4615
+ );
4616
+ }
4617
+ if (merged)
4618
+ ui.hint(
4619
+ `the scaffold merge is still staged; undo it with: git -C ${relative5(cwd, ws.rnessDir) || "."} merge --abort`,
4620
+ "stderr"
4621
+ );
4622
+ if (/minimum.?release.?age|NO_MATURE/i.test(message2))
4623
+ ui.hint(
4624
+ "pnpm refuses versions published less than 24 h ago: add '@rness/cli' under minimumReleaseAgeExclude in .rness/pnpm-workspace.yaml",
4625
+ "stderr"
4626
+ );
4627
+ return 1;
4552
4628
  }
4553
- if (merged)
4554
- ui.hint(
4555
- `the scaffold merge is still staged; undo it with: git -C ${relative5(cwd, ws.rnessDir) || "."} merge --abort`,
4556
- "stderr"
4557
- );
4558
- if (/minimum.?release.?age|NO_MATURE/i.test(message))
4559
- ui.hint(
4560
- "pnpm refuses versions published less than 24 h ago: add '@rness/cli' under minimumReleaseAgeExclude in .rness/pnpm-workspace.yaml",
4561
- "stderr"
4562
- );
4563
- return 1;
4564
- }
4565
- const now = (await readPinnedCli(ws.rnessDir))?.version ?? null;
4566
- if (now !== target) {
4567
- process.stderr.write(
4568
- `${pm} install left @rness/cli ${now ?? "not installed"} in ${label}, not ${target}
4629
+ const now = (await readPinnedCli(ws.rnessDir))?.version ?? null;
4630
+ if (now !== target) {
4631
+ process.stderr.write(
4632
+ `${pm} install left @rness/cli ${now ?? "not installed"} in ${label}, not ${target}
4569
4633
  `
4570
- );
4571
- return 1;
4634
+ );
4635
+ return 1;
4636
+ }
4572
4637
  }
4573
4638
  ui.gitIsSilent = true;
4574
4639
  const code = await syncBlocks(ui, ws.root, basename5(ws.root));
4575
4640
  if (code !== 0) return code;
4576
- return finish(ui, cwd, ws.rnessDir, label, target);
4641
+ const message = `chore: rness ${target}`;
4642
+ const committed = await commitUpgrade(ws.rnessDir, message);
4643
+ if (committed.kind === "committed")
4644
+ ui.line(
4645
+ "committed",
4646
+ `${label} \u2014 ${message}`,
4647
+ `Committed ${label}: ${message}`
4648
+ );
4649
+ if (committed.kind === "refused") {
4650
+ ui.error(
4651
+ `the commit of ${label} was refused; everything is staged \u2014 deal with what git said, then commit`
4652
+ );
4653
+ for (const line2 of committed.output.split("\n"))
4654
+ if (line2.trim() !== "") ui.hint(line2, "stderr");
4655
+ }
4656
+ return finish(ui, cwd, ws.rnessDir, label, target, {
4657
+ commit: committed.kind,
4658
+ clones: await clonesToCommit(
4659
+ ws.root,
4660
+ cwd,
4661
+ await loadManifest(ws.rnessDir)
4662
+ )
4663
+ });
4577
4664
  } catch (e) {
4578
4665
  return reportError(e);
4579
4666
  }
@@ -5416,4 +5503,4 @@ export {
5416
5503
  OWN_COMMANDS,
5417
5504
  catchUp
5418
5505
  };
5419
- //# sourceMappingURL=chunk-DL72BCPT.js.map
5506
+ //# sourceMappingURL=chunk-N3F6N6GA.js.map