@basou/cli 0.26.0 → 0.27.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/dist/index.js CHANGED
@@ -3365,6 +3365,7 @@ async function assertWorkspaceInitialized7(basouRoot) {
3365
3365
  // src/commands/project.ts
3366
3366
  import {
3367
3367
  closeSync,
3368
+ copyFileSync,
3368
3369
  existsSync,
3369
3370
  constants as fsConstants,
3370
3371
  ftruncateSync,
@@ -3385,6 +3386,7 @@ import { basename as basename4, dirname as dirname2, isAbsolute as isAbsolute3,
3385
3386
  import {
3386
3387
  appendBasouGitignore as appendBasouGitignore2,
3387
3388
  basouPaths as basouPaths10,
3389
+ classifyRetrofit,
3388
3390
  createManifest as createManifest2,
3389
3391
  ensureBasouDirectory as ensureBasouDirectory2,
3390
3392
  GENERATED_END,
@@ -3505,6 +3507,17 @@ function registerProjectCommand(program2) {
3505
3507
  ).option("--apply", "Run every step in apply mode (default: dry-run preview)").option("-v, --verbose", "Show error causes").action(async (opts) => {
3506
3508
  await runProjectDerive(opts);
3507
3509
  });
3510
+ project.command("retrofit").argument(
3511
+ "<repo>",
3512
+ "The declared roster repo whose hand-authored AGENTS.md to relocate (e.g. ../foo)"
3513
+ ).description(
3514
+ "Fold an existing repo's hand-authored AGENTS.md into the project topology: move the repo's regular-file `AGENTS.md` to the anchor canonical (`agents/<repo>/AGENTS.md`) and replace it with a symlink, so the prose lives at the single source of truth. Dry-run by default; pass --apply to relocate. The onboarding counterpart to `new` for a repo that already carries its own AGENTS.md \u2014 run it before `basou project derive`, which then adds the preset block, the CLAUDE.md / Copilot spokes, and the .gitignore. Non-destructive: it refuses when the destination canonical already exists (it never clobbers it), and skips a repo whose AGENTS.md is already a symlink or absent. The anchor (`.`) is refused"
3515
+ ).option(
3516
+ "--apply",
3517
+ "Relocate the AGENTS.md to the canonical and recreate the symlink (default: dry-run preview)"
3518
+ ).option("--json", "Output the result as JSON").option("-v, --verbose", "Show error causes").action(async (repo, opts) => {
3519
+ await runProjectRetrofit(repo, opts);
3520
+ });
3508
3521
  }
3509
3522
  async function runProjectCheck(options, ctx = {}) {
3510
3523
  try {
@@ -5683,6 +5696,242 @@ async function doRunProjectDerive(options, ctx) {
5683
5696
  apply ? "\u2705 Ran every step (each is idempotent, so a partial apply recovers on re-run)." : "\u2139\uFE0F Dry-run preview. Pass --apply to write the changes, then re-run."
5684
5697
  );
5685
5698
  }
5699
+ async function runProjectRetrofit(repo, options, ctx = {}) {
5700
+ try {
5701
+ await doRunProjectRetrofit(repo, options, ctx);
5702
+ } catch (error) {
5703
+ renderCliError(error, { verbose: isVerbose(options) });
5704
+ process.exitCode = 1;
5705
+ }
5706
+ }
5707
+ function inspectAgentsState(filePath) {
5708
+ let st;
5709
+ try {
5710
+ st = lstatSync(filePath);
5711
+ } catch (error) {
5712
+ if (hasErrorCode(error) && error.code === "ENOENT") return "absent";
5713
+ return "blocked";
5714
+ }
5715
+ if (st.isSymbolicLink()) return "symlink";
5716
+ return st.isFile() ? "regular-file" : "blocked";
5717
+ }
5718
+ function regularFileSpokes(repoReal) {
5719
+ const out = [];
5720
+ for (const spoke of ["CLAUDE.md", ".github/copilot-instructions.md"]) {
5721
+ try {
5722
+ const st = lstatSync(join7(repoReal, spoke));
5723
+ if (!st.isSymbolicLink() && st.isFile()) out.push(spoke);
5724
+ } catch {
5725
+ }
5726
+ }
5727
+ return out;
5728
+ }
5729
+ function pathPresent(p) {
5730
+ try {
5731
+ lstatSync(p);
5732
+ return true;
5733
+ } catch {
5734
+ return false;
5735
+ }
5736
+ }
5737
+ function gatherRetrofit(repositoryRoot, anchorReal, roster, argPath, argAbs, argReal) {
5738
+ const declared = roster.some((entry) => {
5739
+ const entryAbs = resolve7(repositoryRoot, entry.path);
5740
+ if (argReal !== void 0) {
5741
+ try {
5742
+ if (realpathSync(entryAbs) === argReal) return true;
5743
+ } catch {
5744
+ }
5745
+ }
5746
+ return entryAbs === argAbs;
5747
+ });
5748
+ const displayRel = argReal !== void 0 ? relative2(anchorReal, argReal) : relative2(repositoryRoot, argAbs);
5749
+ const path = displayRel === "" ? "." : displayRel;
5750
+ const canonicalName = basename4(argReal ?? argAbs);
5751
+ if (argReal === void 0) {
5752
+ return {
5753
+ path,
5754
+ declared,
5755
+ isAnchor: false,
5756
+ reachable: false,
5757
+ canonicalName,
5758
+ agentsState: "absent",
5759
+ canonicalExists: false,
5760
+ regularSpokes: []
5761
+ };
5762
+ }
5763
+ const isAnchor = argReal === anchorReal;
5764
+ const reachable = existsSync(join7(argReal, ".git"));
5765
+ const canonicalFile = join7(anchorReal, "agents", canonicalName, CANONICAL_FILE);
5766
+ return {
5767
+ path,
5768
+ declared,
5769
+ isAnchor,
5770
+ reachable,
5771
+ canonicalName,
5772
+ agentsState: inspectAgentsState(join7(argReal, CANONICAL_FILE)),
5773
+ canonicalExists: pathPresent(canonicalFile),
5774
+ regularSpokes: regularFileSpokes(argReal)
5775
+ };
5776
+ }
5777
+ function relocateAgentsFile(repoReal, canonicalFile) {
5778
+ const agentsFile = join7(repoReal, CANONICAL_FILE);
5779
+ try {
5780
+ mkdirSync(dirname2(canonicalFile), { recursive: true });
5781
+ } catch (error) {
5782
+ return { ok: false, message: failureReason(error), partial: false };
5783
+ }
5784
+ try {
5785
+ copyFileSync(agentsFile, canonicalFile, fsConstants.COPYFILE_EXCL);
5786
+ } catch (error) {
5787
+ const message = hasErrorCode(error) && error.code === "EEXIST" ? "canonical-exists" : failureReason(error);
5788
+ return { ok: false, message, partial: false };
5789
+ }
5790
+ try {
5791
+ unlinkSync(agentsFile);
5792
+ symlinkSync(relative2(repoReal, canonicalFile), agentsFile);
5793
+ return { ok: true };
5794
+ } catch (error) {
5795
+ return { ok: false, message: failureReason(error), partial: true };
5796
+ }
5797
+ }
5798
+ async function doRunProjectRetrofit(repo, options, ctx) {
5799
+ const cwd = ctx.cwd ?? process.cwd();
5800
+ const repositoryRoot = await resolveBasouRootForCommand(cwd, "project retrofit");
5801
+ const paths = basouPaths10(repositoryRoot);
5802
+ const manifest = await readManifest6(paths);
5803
+ const roster = manifest.repos ?? [];
5804
+ const anchorReal = realpathSync(repositoryRoot);
5805
+ const argAbs = resolve7(repositoryRoot, repo);
5806
+ let argReal;
5807
+ try {
5808
+ argReal = realpathSync(argAbs);
5809
+ } catch {
5810
+ argReal = void 0;
5811
+ }
5812
+ const facts = gatherRetrofit(repositoryRoot, anchorReal, roster, repo, argAbs, argReal);
5813
+ const plan = classifyRetrofit(facts);
5814
+ let applied = false;
5815
+ let failure;
5816
+ let partial = false;
5817
+ if (options.apply === true && plan.action === "relocate" && argReal !== void 0) {
5818
+ const canonicalFile = join7(anchorReal, "agents", plan.canonicalName, CANONICAL_FILE);
5819
+ const res = relocateAgentsFile(argReal, canonicalFile);
5820
+ if (res.ok) {
5821
+ applied = true;
5822
+ } else {
5823
+ failure = res.message;
5824
+ partial = res.partial;
5825
+ }
5826
+ }
5827
+ const result = {
5828
+ ...plan,
5829
+ hasRoster: roster.length > 0,
5830
+ applied,
5831
+ ...failure !== void 0 ? { failure } : {},
5832
+ ...partial ? { partial } : {}
5833
+ };
5834
+ if (options.json === true) {
5835
+ console.log(JSON.stringify(result));
5836
+ } else {
5837
+ console.log(renderProjectRetrofit(result));
5838
+ }
5839
+ return result;
5840
+ }
5841
+ function appendSpokeChecklist(lines, spokes) {
5842
+ if (spokes.length === 0) return;
5843
+ lines.push(
5844
+ `## Spoke files to reconcile (${spokes.length}) \u2014 regular files that would block clean wiring`
5845
+ );
5846
+ for (const s of spokes) {
5847
+ lines.push(
5848
+ `- ${s}: a regular file. If it duplicates AGENTS.md, remove it; if it carries unique content, merge it into AGENTS.md. Then run \`basou project symlinks\`.`
5849
+ );
5850
+ }
5851
+ lines.push("");
5852
+ }
5853
+ function renderProjectRetrofit(result) {
5854
+ const lines = [];
5855
+ lines.push(
5856
+ "# Retrofit an existing AGENTS.md into the project (relocate to the anchor canonical)"
5857
+ );
5858
+ lines.push("");
5859
+ if (!result.hasRoster) {
5860
+ lines.push(
5861
+ "\u2139\uFE0F No repo roster declared (manifest `repos`). Declare the repo first with `basou project new` (or `basou project adopt`), then re-run."
5862
+ );
5863
+ return lines.join("\n");
5864
+ }
5865
+ const canonical2 = `agents/${result.canonicalName}/${CANONICAL_FILE}`;
5866
+ if (result.action === "refuse") {
5867
+ if (result.reason === "not-declared") {
5868
+ lines.push(
5869
+ `\u2139\uFE0F \`${result.path}\` is not declared in the roster (manifest \`repos\`). Add it first with \`basou project new\` / \`basou project adopt\`, then re-run.`
5870
+ );
5871
+ } else if (result.reason === "anchor") {
5872
+ lines.push(
5873
+ `\u26A0\uFE0F \`${result.path}\` is the anchor (the project root). It owns its canonical directly \u2014 there is nothing to relocate.`
5874
+ );
5875
+ } else if (result.reason === "unreachable") {
5876
+ lines.push(
5877
+ `\u26A0\uFE0F \`${result.path}\` does not resolve to a git repository. There is nothing to retrofit.`
5878
+ );
5879
+ } else if (result.reason === "blocked") {
5880
+ lines.push(
5881
+ `\u26A0\uFE0F \`${result.path}/${CANONICAL_FILE}\` could not be inspected (a parent component is not a directory, a permission error, or the path is neither a regular file nor a symlink). Resolve it by hand, then re-run.`
5882
+ );
5883
+ } else if (result.reason === "canonical-exists") {
5884
+ lines.push(
5885
+ `\u26A0\uFE0F The destination canonical \`${canonical2}\` already exists. Not relocating, to avoid clobbering it. If the canonical is the source of truth, the repo's AGENTS.md is redundant (remove it, then run \`basou project symlinks\`); otherwise reconcile the two by hand.`
5886
+ );
5887
+ }
5888
+ return lines.join("\n");
5889
+ }
5890
+ if (result.action === "skip") {
5891
+ if (result.reason === "already-symlink") {
5892
+ lines.push(
5893
+ `\u2705 \`${result.path}/${CANONICAL_FILE}\` is already a symlink (already wired). Nothing to retrofit.`
5894
+ );
5895
+ } else {
5896
+ lines.push(
5897
+ `\u2139\uFE0F \`${result.path}\` has no regular-file \`${CANONICAL_FILE}\` to relocate. If it should have a canonical, run \`basou project derive\` to generate one from the manifest.`
5898
+ );
5899
+ }
5900
+ lines.push("");
5901
+ appendSpokeChecklist(lines, result.regularSpokes);
5902
+ return lines.join("\n").trimEnd();
5903
+ }
5904
+ if (result.failure !== void 0) {
5905
+ lines.push(
5906
+ `Could not relocate \`${result.path}/${CANONICAL_FILE}\` to \`${canonical2}\`: ${result.failure}.`
5907
+ );
5908
+ if (result.partial === true) {
5909
+ lines.push(
5910
+ `The canonical \`${canonical2}\` was written, but \`${result.path}/${CANONICAL_FILE}\` may be absent. Run \`basou project symlinks --apply\` (or \`basou project derive --apply\`) to recreate the missing link, then verify.`
5911
+ );
5912
+ } else {
5913
+ lines.push("Nothing was changed. Resolve the cause and re-run.");
5914
+ }
5915
+ return lines.join("\n");
5916
+ }
5917
+ if (result.applied) {
5918
+ lines.push(
5919
+ `\u2705 Relocated \`${result.path}/${CANONICAL_FILE}\` to \`${canonical2}\` and left a symlink in its place.`
5920
+ );
5921
+ } else {
5922
+ lines.push(
5923
+ `To relocate \`${result.path}/${CANONICAL_FILE}\` and replace it with a symlink (dry-run; pass --apply to write):`
5924
+ );
5925
+ lines.push(` move ${result.path}/${CANONICAL_FILE} -> ${canonical2}`);
5926
+ lines.push(` symlink ${result.path}/${CANONICAL_FILE} -> the canonical`);
5927
+ }
5928
+ lines.push("");
5929
+ appendSpokeChecklist(lines, result.regularSpokes);
5930
+ lines.push(
5931
+ result.applied ? "Next: run `basou project derive --apply` to add the preset block, the CLAUDE.md / Copilot spokes, and the .gitignore." : "After applying, run `basou project derive --apply` to finish the wiring (preset block, CLAUDE.md / Copilot spokes, .gitignore)."
5932
+ );
5933
+ return lines.join("\n");
5934
+ }
5686
5935
 
5687
5936
  // src/commands/protocol.ts
5688
5937
  import { readFile as readFile4 } from "fs/promises";