open Cmdliner let setup_logging style_renderer level verbose_http = Fmt_tty.setup_std_outputs ?style_renderer (); Logs.set_reporter (Logs_fmt.reporter ()); (* Set global log level for monopam's own logs *) Logs.set_level level; (* Use Requests.Cmd.setup_log_sources to configure HTTP logging separately. This allows -v to show app logs without HTTP protocol details, while --verbose-http enables full HTTP tracing. *) Requests.Cmd.setup_log_sources ~verbose_http level let logging_term = let verbose_http_term = Term.( const (fun ws -> ws.Requests.Cmd.value) $ Requests.Cmd.verbose_http_term "monopam") in Term.( const setup_logging $ Fmt_cli.style_renderer () $ Logs_cli.level () $ verbose_http_term) let package_arg = let doc = "Package name. If not specified, operates on all packages." in Arg.(value & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc) (* Load config from opamverse.toml *) let load_config env = let fs = Eio.Stdenv.fs env in Monopam.Config.load ~fs () let with_config env f = match load_config env with | Ok config -> f config | Error msg -> Fmt.epr "Error loading config: %s@." msg; Fmt.epr "Run 'monopam init' first to create a workspace.@."; `Error (false, "configuration error") (* Status command *) let status_cmd = let doc = "Show synchronization status of all packages" in let man = [ `S Manpage.s_description; `P "Displays package status showing both local sync state (monorepo vs \ checkout) and remote sync state (checkout vs upstream)."; `S "STATUS COLUMNS"; `P "Each repository shows two sync indicators:"; `I ("local:", "Sync between your monorepo (mono/) and checkout (src/)"); `I ("remote:", "Sync between your checkout (src/) and upstream git remote"); `S "LOCAL SYNC INDICATORS"; `I ("local:=", "Monorepo and checkout are in sync"); `I ( "local:+N", "Monorepo has N commits not yet in checkout (run $(b,monopam sync))" ); `I ( "local:-N", "Checkout has N commits not yet in monorepo (run $(b,monopam sync))" ); `I ("local:sync", "Trees differ, needs sync (run $(b,monopam sync))"); `S "REMOTE SYNC INDICATORS"; `I ("remote:=", "Checkout and upstream remote are in sync"); `I ( "remote:+N", "Checkout has N commits to push (run $(b,monopam sync --remote))" ); `I ("remote:-N", "Upstream has N commits to pull (run $(b,monopam sync))"); `I ("remote:+N/-M", "Diverged: checkout +N ahead, upstream +M ahead"); `S "FORK ANALYSIS"; `P "If tracking other members via verse, shows fork comparison:"; `I ("+N", "They have N commits you don't have"); `I ("-N", "You have N commits they don't have"); `I ("=", "Same commit or same URL"); `I ("~", "Not in your workspace (use --all to list)"); `S "NEXT STEPS"; `P "Based on the status output:"; `I ("local:+N or local:-N", "Run $(b,monopam sync) to synchronize"); `I ("remote:-N", "Run $(b,monopam sync) to pull upstream changes"); `I ("remote:+N", "Run $(b,monopam sync --remote) to push to upstream"); ] in let info = Cmd.info "status" ~doc ~man in let all_arg = let doc = "Show all repos including those not in your workspace." in Arg.(value & flag & info [ "all"; "a" ] ~doc) in let run show_all () = Eio_main.run @@ fun env -> with_config env @@ fun config -> let fs = Eio.Stdenv.fs env in let proc = Eio.Stdenv.process_mgr env in match Monopam.status ~proc ~fs ~config () with | Ok statuses -> (* Load sources.toml for origin indicators *) let sources = let mono_path = Monopam.Config.Paths.monorepo config in let sources_path = Fpath.(mono_path / "sources.toml") in match Monopam.Sources_registry.load ~fs:(fs :> _ Eio.Path.t) sources_path with | Ok s -> Some s | Error _ -> None in Fmt.pr "%a" (Monopam.Status.pp_summary ?sources) statuses; (* Check for unregistered opam files *) (match Monopam.discover_packages ~fs ~config () with | Ok pkgs -> let unregistered = Monopam.find_unregistered_opam_files ~fs ~config pkgs in if unregistered <> [] then begin (* Get local handle abbreviation *) let handle_abbrev = match Monopam.Verse_config.load ~fs () with | Ok vc -> ( let h = Monopam.Verse_config.handle vc in match String.split_on_char '.' h with | first :: _ -> if String.length first <= 4 then first else String.sub first 0 3 | [] -> h) | Error _ -> "local" in Fmt.pr "%a %a\n" Fmt.(styled `Bold string) "Unregistered:" Fmt.(styled `Faint int) (List.length unregistered); List.iter (fun (_r, p) -> Fmt.pr " %-22s %a\n" p Fmt.(styled `Faint (fun ppf s -> pf ppf "%s~" s)) handle_abbrev) unregistered end | Error _ -> ()); (* Fork analysis *) (match Monopam.Verse_config.load ~fs () with | Error _ -> () | Ok verse_config -> let forks = Monopam.Forks.compute ~proc ~fs ~verse_config ~monopam_config:config () in if forks.repos <> [] then Fmt.pr "%a" (Monopam.Forks.pp_summary' ~show_all) forks); `Ok () | Error e -> Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e; `Error (false, "status failed") in Cmd.v info Term.(ret (const run $ all_arg $ logging_term)) (* Sync command *) let sync_cmd = let doc = "Synchronize monorepo with upstream repositories" in let man = [ `S Manpage.s_description; `P "$(b,This is the primary command for all workflows.) It performs both \ push and pull operations in the correct order to fully synchronize \ your monorepo with upstream repositories."; `S "COMMON USAGE"; `I ("monopam sync", "Full sync: push local changes + pull remote changes"); `I ("monopam sync --remote", "Full sync + push to upstream git remotes"); `I ("monopam sync eio", "Sync only the eio repository"); `I ("monopam sync --skip-push", "Pull only: skip exporting local changes"); `I ("monopam sync --skip-pull", "Push only: skip fetching remote changes"); `S Manpage.s_examples; `P "After making changes:"; `Pre "cd mono\n\ # ... edit files ...\n\ git add -A && git commit -m \"Add feature\"\n\ monopam sync --remote # sync and push upstream"; `P "Pull latest from all upstreams (no local changes to export):"; `Pre "monopam sync --skip-push"; `P "Export local changes for review without pulling:"; `Pre "monopam sync --skip-pull"; `S "PHASES"; `P "The sync command executes these phases in order:"; `I ("1. Validate", "Abort if the monorepo has uncommitted changes"); `I ( "2. Push", "Export monorepo changes to checkouts (parallel) [--skip-push skips]" ); `I ("3. Fetch", "Clone/fetch from remotes (parallel) [--skip-pull skips]"); `I ("4. Merge", "Fast-forward merge checkouts [--skip-pull skips]"); `I ("5. Subtree", "Pull subtrees into monorepo [--skip-pull skips]"); `I ("6. Finalize", "Update README.md, CLAUDE.md, and dune-project"); `I ("7. Remote", "Push to upstream remotes if --remote (parallel)"); `S "SKIP OPTIONS"; `I ( "--skip-push", "Skip exporting monorepo changes to checkouts. Use when you know you \ have no local changes to export." ); `I ( "--skip-pull", "Skip fetching and pulling from remotes. Use when you only want to \ export local changes without pulling remote updates." ); `S "PREREQUISITES"; `P "Before running sync:"; `I ( "-", "Commit all changes in the monorepo: $(b,git add -A && git commit)" ); `I ("-", "For --remote: ensure git credentials/SSH keys are configured"); ] in let info = Cmd.info "sync" ~doc ~man in let remote_arg = let doc = "Also push each checkout to its upstream git remote after syncing." in Arg.(value & flag & info [ "remote" ] ~doc) in let skip_push_arg = let doc = "Skip exporting monorepo changes to checkouts." in Arg.(value & flag & info [ "skip-push" ] ~doc) in let skip_pull_arg = let doc = "Skip fetching and pulling from remotes." in Arg.(value & flag & info [ "skip-pull" ] ~doc) in let run package remote skip_push skip_pull () = Eio_main.run @@ fun env -> with_config env @@ fun config -> let fs = Eio.Stdenv.fs env in let proc = Eio.Stdenv.process_mgr env in match Monopam.sync ~proc ~fs ~config ?package ~remote ~skip_push ~skip_pull () with | Ok summary -> if summary.errors = [] then `Ok () else begin Fmt.epr "Sync completed with %d errors.@." (List.length summary.errors); `Ok () end | Error e -> Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e; `Error (false, "sync failed") in Cmd.v info Term.( ret (const run $ package_arg $ remote_arg $ skip_push_arg $ skip_pull_arg $ logging_term)) (* Changes command *) let changes_cmd = let doc = "Generate changelog entries using Claude AI" in let man = [ `S Manpage.s_description; `P "Analyzes git commit history and generates user-facing changelogs."; `P "By default, generates weekly entries. Use --daily to generate daily \ entries instead."; `P "Changes are stored in the .changes directory at the monorepo root:"; `I (".changes/.json", "Weekly changelog entries"); `I (".changes/-daily.json", "Daily changelog entries"); `I ( ".changes/YYYYMMDD.json", "Aggregated daily entries (default with --daily)" ); `P "Also generates aggregated markdown files at the monorepo root:"; `I ("CHANGES.md", "Aggregated weekly changelog"); `I ("DAILY-CHANGES.md", "Aggregated daily changelog"); `P "Each entry includes:"; `I ("summary", "A one-line summary of the most important change"); `I ("changes", "Up to 5 bullet points describing user-facing changes"); `I ("commit_range", "The range of commits included in the entry"); `P "Claude AI analyzes commits and generates changelog text focused on \ user-facing changes. Internal refactoring, CI tweaks, and typo fixes \ are automatically filtered out."; `P "Repositories with no user-facing changes will have blank entries \ (empty summary and changes) rather than 'no changes' text."; `P "When using --daily, an aggregated JSON file is generated by default \ for the poe Zulip bot broadcasting system. Use --no-aggregate to \ skip."; `P "If a per-repo-per-day JSON file already exists for a past day, that \ repo is skipped for that day to avoid redundant Claude API calls."; ] in let info = Cmd.info "changes" ~doc ~man in let daily = let doc = "Generate daily changelog entries instead of weekly" in Arg.(value & flag & info [ "daily"; "d" ] ~doc) in let weeks = let doc = "Number of past weeks to analyze (default: 1, current week only). \ Ignored if --daily is set." in Arg.(value & opt int 1 & info [ "w"; "weeks" ] ~doc) in let days = let doc = "Number of past days to analyze when using --daily (default: 1, today \ only)" in Arg.(value & opt int 1 & info [ "days" ] ~doc) in let history = let doc = "Number of recent entries to include in aggregated markdown (default: 12 \ for weekly, 30 for daily)" in Arg.(value & opt int 12 & info [ "history" ] ~doc) in let dry_run = let doc = "Preview changes without writing files" in Arg.(value & flag & info [ "dry-run"; "n" ] ~doc) in let no_aggregate = let doc = "Skip generating .changes/YYYYMMDD.json aggregated file (--daily \ generates it by default)" in Arg.(value & flag & info [ "no-aggregate" ] ~doc) in let run package daily weeks days history dry_run no_aggregate () = Eio_main.run @@ fun env -> with_config env @@ fun config -> let fs = Eio.Stdenv.fs env in let proc = Eio.Stdenv.process_mgr env in let clock = Eio.Stdenv.clock env in let result = if daily then begin (* Use 30 as default history for daily if not explicitly set *) let history = if history = 12 then 30 else history in (* Aggregate by default for daily, unless --no-aggregate is passed *) let aggregate = not no_aggregate in Monopam.changes_daily ~proc ~fs ~config ~clock ?package ~days ~history ~dry_run ~aggregate () end else Monopam.changes ~proc ~fs ~config ~clock ?package ~weeks ~history ~dry_run () in match result with | Ok () -> if dry_run then Fmt.pr "Dry run complete.@." else if daily then Fmt.pr "Daily changelog updated.@." else Fmt.pr "Weekly changelog updated.@."; `Ok () | Error e -> Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e; `Error (false, "changes failed") in Cmd.v info Term.( ret (const run $ package_arg $ daily $ weeks $ days $ history $ dry_run $ no_aggregate $ logging_term)) (* Opam commands *) let opam_sync_cmd = let doc = "Sync opam files from monorepo to opam-repo" in let man = [ `S Manpage.s_description; `P "Copies .opam files from monorepo subtrees to the opam-repo overlay. \ This ensures your opam overlay reflects any changes you made to .opam \ files in the monorepo."; `S "HOW IT WORKS"; `P "For each package in your opam overlay:"; `I ( "1.", "Reads the .opam file from the monorepo subtree (e.g., \ mono/eio/eio.opam)" ); `I ( "2.", "Compares with the opam-repo version (e.g., \ opam-repo/packages/eio/eio.dev/opam)" ); `I ("3.", "If different, copies monorepo → opam-repo"); `I ("4.", "Stages and commits changes in opam-repo"); `S "PRECEDENCE"; `P "Local always wins: the monorepo version is the source of truth."; `S Manpage.s_examples; `P "Sync all packages:"; `Pre "monopam opam sync"; `P "Sync a specific package:"; `Pre "monopam opam sync eio"; ] in let info = Cmd.info "sync" ~doc ~man in let run package () = Eio_main.run @@ fun env -> with_config env @@ fun config -> let fs = Eio.Stdenv.fs env in let proc = Eio.Stdenv.process_mgr env in match Monopam.sync_opam_files ~proc ~fs ~config ?package () with | Ok result -> if result.synced = [] then Fmt.pr "All opam files already in sync.@." else Fmt.pr "Synced %d opam files.@." (List.length result.synced); `Ok () | Error e -> Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e; `Error (false, "opam sync failed") in Cmd.v info Term.(ret (const run $ package_arg $ logging_term)) let opam_cmd = let doc = "Opam metadata management" in let man = [ `S Manpage.s_description; `P "Commands for managing opam metadata between your monorepo and the \ opam-repo overlay."; `S "COMMANDS"; `I ("sync", "Sync .opam files from monorepo subtrees to opam-repo"); ] in let info = Cmd.info "opam" ~doc ~man in Cmd.group info [ opam_sync_cmd ] (* Init command - initialize a new monopam workspace *) let init_root_arg = let doc = "Path to workspace root directory. Defaults to current directory." in Arg.( value & opt (some (conv (Fpath.of_string, Fpath.pp))) None & info [ "root" ] ~docv:"PATH" ~doc) let init_handle_arg = let doc = "Your handle (e.g., alice.bsky.social)" in Arg.( required & opt (some string) None & info [ "handle" ] ~docv:"HANDLE" ~doc) let init_cmd = let doc = "Initialize a new monopam workspace" in let man = [ `S Manpage.s_description; `P "Creates a new monopam workspace for monorepo development. The workspace \ lets you manage your own monorepo and optionally browse and track other \ developers' monorepos."; `S "WORKSPACE STRUCTURE"; `P "The init command creates the following directory structure at the \ workspace root:"; `I ("mono/", "Your monorepo - use with standard monopam commands"); `I ("src/", "Your source checkouts - individual git repos"); `I ("verse/", "Other users' monorepos, organized by handle"); `P "Configuration and data are stored in XDG directories:"; `I ("~/.config/monopam/opamverse.toml", "Workspace configuration"); `I ( "~/.local/share/monopam/opamverse-registry/", "Git clone of the community registry" ); `S "CONFIGURATION FILE"; `P "The opamverse.toml file has the following structure:"; `Pre "[workspace]\n\ root = \"/path/to/workspace\"\n\ default_branch = \"main\"\n\n\ [paths]\n\ mono = \"mono\"\n\ src = \"src\"\n\ verse = \"verse\"\n\n\ [identity]\n\ handle = \"yourname.bsky.social\""; `S "HANDLE VALIDATION"; `P "The handle you provide identifies you in the community. \ It should be a valid domain name (e.g., yourname.bsky.social or \ your-domain.com)."; `S "REGISTRY"; `P "The registry is a git repository containing an opamverse.toml file \ that lists community members and their monorepo URLs. The default \ registry is at: https://tangled.org/eeg.cl.cam.ac.uk/opamverse"; `S Manpage.s_examples; `P "Initialize a workspace in the current directory:"; `Pre "monopam init --handle alice.bsky.social"; `P "Initialize with explicit root path:"; `Pre "monopam init --root ~/my-workspace --handle alice.bsky.social"; ] in let info = Cmd.info "init" ~doc ~man in let run root handle () = Eio_main.run @@ fun env -> let fs = Eio.Stdenv.fs env in let proc = Eio.Stdenv.process_mgr env in let root = match root with | Some r -> r | None -> ( let cwd_path = Eio.Stdenv.cwd env in let _, cwd_str = (cwd_path :> _ Eio.Path.t) in match Fpath.of_string cwd_str with | Ok p -> p | Error (`Msg _) -> Fpath.v ".") in match Monopam.Verse.init ~proc ~fs ~root ~handle () with | Ok () -> Fmt.pr "Workspace initialized at %a@." Fpath.pp root; `Ok () | Error e -> Fmt.epr "Error: %a@." Monopam.Verse.pp_error_with_hint e; `Error (false, "init failed") in Cmd.v info Term.(ret (const run $ init_root_arg $ init_handle_arg $ logging_term)) (* Verse commands *) (* Helper to load verse config from XDG *) let with_verse_config env f = let fs = Eio.Stdenv.fs env in match Monopam.Verse_config.load ~fs () with | Ok config -> f config | Error msg -> Fmt.epr "Error loading opamverse config: %s@." msg; Fmt.epr "Run 'monopam init' to create a workspace.@."; `Error (false, "configuration error") let verse_members_cmd = let doc = "List registry members" in let man = [ `S Manpage.s_description; `P "Lists all members registered in the opamverse community registry. \ This shows everyone who has published their monorepo for \ collaboration."; `P "The registry is automatically pulled (git pull) when running this \ command to ensure you see the latest members."; `S "REGISTRY FORMAT"; `P "The registry is a git repository containing an opamverse.toml file \ with the following structure:"; `Pre "[registry]\n\ name = \"tangled-community\"\n\n\ [[members]]\n\ handle = \"alice.bsky.social\"\n\ monorepo = \"https://github.com/alice/mono\"\n\n\ [[members]]\n\ handle = \"bob.example.com\"\n\ monorepo = \"https://github.com/bob/mono\""; `S "OUTPUT"; `P "Each line shows a member's handle and their monorepo git URL:"; `Pre "alice.bsky.social -> https://github.com/alice/mono\n\ bob.example.com -> https://github.com/bob/mono"; `S "ADDING YOURSELF"; `P "To add yourself to the registry, submit a pull request to the \ registry repository adding your entry to opamverse.toml."; ] in let info = Cmd.info "members" ~doc ~man in let run () = Eio_main.run @@ fun env -> with_verse_config env @@ fun config -> let fs = Eio.Stdenv.fs env in let proc = Eio.Stdenv.process_mgr env in match Monopam.Verse.members ~proc ~fs ~config () with | Ok members -> Fmt.pr "@[%a@]@." Fmt.(list ~sep:cut Monopam.Verse_registry.pp_member) members; `Ok () | Error e -> Fmt.epr "Error: %a@." Monopam.Verse.pp_error_with_hint e; `Error (false, "members failed") in Cmd.v info Term.(ret (const run $ logging_term)) let verse_fork_cmd = let doc = "Fork a package from a verse member's repository" in let man = [ `S Manpage.s_description; `P "Fork a package from a verse member's opam repository into your workspace. \ This creates entries in your opam-repo with your fork URL as the dev-repo."; `P "The command finds all packages sharing the same git repository and forks \ them together. For example, if you fork 'cohttp', it will also fork \ cohttp-eio, cohttp-lwt, etc."; `S "WHAT IT DOES"; `P "For the specified package:"; `I ("1.", "Looks up the package in 's opam-repo (verse/-opam/)"); `I ("2.", "Finds all packages from the same git repository"); `I ("3.", "Creates entries in your opam-repo with your fork URL"); `P "After forking:"; `I ("1.", "Commit the new opam files: $(b,cd opam-repo && git add -A && git commit)"); `I ("2.", "Run $(b,monopam sync) to pull the fork into your monorepo"); `S "PREREQUISITES"; `P "Before forking:"; `I ("-", "Run $(b,monopam verse pull ) to sync the member's opam-repo"); `I ("-", "Create a fork of the repository on GitHub/GitLab/etc."); `S Manpage.s_examples; `P "Fork a package from a verse member:"; `Pre "monopam fork http2 --from sadiq.bsky.social --url git@github.com:me/http2.git"; `P "Preview what would be forked (multi-package repos):"; `Pre "monopam fork cohttp --from avsm.bsky.social --url git@github.com:me/cohttp.git --dry-run\n\ Would fork 5 packages from cohttp repository:\n\ \ cohttp\n\ \ cohttp-eio\n\ \ cohttp-lwt\n\ \ cohttp-async\n\ \ cohttp-mirage"; `P "After forking, commit and sync:"; `Pre "cd opam-repo && git add -A && git commit -m \"Fork cohttp\"\n\ monopam sync"; `S "ERRORS"; `P "The command will fail if any package from the source repo already exists \ in your opam-repo. Remove conflicting packages first with:"; `Pre "rm -rf opam-repo/packages/"; ] in let info = Cmd.info "fork" ~doc ~man in let package_arg = let doc = "Package name to fork (e.g., 'cohttp', 'eio')" in Arg.(required & pos 0 (some string) None & info [] ~docv:"PACKAGE" ~doc) in let from_arg = let doc = "Verse member handle to fork from (e.g., 'avsm.bsky.social')" in Arg.(required & opt (some string) None & info [ "from" ] ~docv:"HANDLE" ~doc) in let url_arg = let doc = "Git URL of your fork (e.g., 'git@github.com:you/repo.git')" in Arg.(required & opt (some string) None & info [ "url" ] ~docv:"URL" ~doc) in let dry_run_arg = let doc = "Show what would be forked without making changes" in Arg.(value & flag & info [ "dry-run"; "n" ] ~doc) in let run package handle fork_url dry_run () = Eio_main.run @@ fun env -> with_verse_config env @@ fun config -> let fs = Eio.Stdenv.fs env in let proc = Eio.Stdenv.process_mgr env in match Monopam.Verse.fork ~proc ~fs ~config ~handle ~package ~fork_url ~dry_run () with | Ok result -> if dry_run then begin Fmt.pr "Would fork %d package(s) from %s:@." (List.length result.packages_forked) result.source_handle; List.iter (fun p -> Fmt.pr " %s@." p) result.packages_forked end else begin (* Update sources.toml with fork information *) let mono_path = Monopam.Verse_config.mono_path config in let sources_path = Fpath.(mono_path / "sources.toml") in let sources = match Monopam.Sources_registry.load ~fs:(fs :> _ Eio.Path.t) sources_path with | Ok s -> s | Error _ -> Monopam.Sources_registry.empty in let entry = Monopam.Sources_registry.{ url = result.fork_url; upstream = Some result.upstream_url; branch = None; reason = Some (Fmt.str "Forked from %s" result.source_handle); origin = Some Join; (* Forked from verse = joined *) } in let sources = Monopam.Sources_registry.add sources ~subtree:result.subtree_name entry in (match Monopam.Sources_registry.save ~fs:(fs :> _ Eio.Path.t) sources_path sources with | Ok () -> Fmt.pr "Updated sources.toml with fork entry for %s@." result.subtree_name | Error msg -> Fmt.epr "Warning: Failed to update sources.toml: %s@." msg); Fmt.pr "Forked %d package(s): %a@." (List.length result.packages_forked) Fmt.(list ~sep:(any ", ") string) result.packages_forked; Fmt.pr "@.Next steps:@."; Fmt.pr " 1. cd opam-repo && git add -A && git commit -m \"Fork ...\"@."; Fmt.pr " 2. monopam sync@." end; `Ok () | Error e -> Fmt.epr "Error: %a@." Monopam.Verse.pp_error_with_hint e; `Error (false, "fork failed") in Cmd.v info Term.(ret (const run $ package_arg $ from_arg $ url_arg $ dry_run_arg $ logging_term)) let verse_cmd = let doc = "Verse member operations" in let man = [ `S Manpage.s_description; `P "Commands for working with verse community members. The verse system \ enables federated collaboration across multiple developers' monorepos."; `P "Members are identified by handles - typically domain names like \ 'yourname.bsky.social' or 'your-domain.com'."; `S "NOTE"; `P "The $(b,monopam init) command creates your workspace and \ $(b,monopam sync) automatically syncs verse members. These commands \ are for additional verse-specific operations."; `S "COMMANDS"; `I ("members", "List all members in the community registry"); `I ("fork --from --url ", "Fork a package from a verse member"); `S Manpage.s_examples; `P "List all community members:"; `Pre "monopam verse members"; `P "Fork a package from another member:"; `Pre "monopam verse fork cohttp --from avsm.bsky.social --url git@github.com:me/cohttp.git"; ] in let info = Cmd.info "verse" ~doc ~man in Cmd.group info [ verse_members_cmd; verse_fork_cmd; ] (* Diff command *) let diff_cmd = let doc = "Show diffs from verse members for repos needing attention" in let man = [ `S Manpage.s_description; `P "Shows commit diffs from verse members for repositories where they have \ commits you don't have. This helps you see what changes are available \ from collaborators."; `S "OUTPUT"; `P "First shows the verse status summary, then for each repository where \ a verse member is ahead:"; `I ("Repository name", "With the handle and relationship"); `I ("Commits", "List of commits they have that you don't (max 20)"); `S "RELATIONSHIPS"; `I ("+N", "They have N commits you don't have"); `I ("+N/-M", "Diverged: they have N new commits, you have M new commits"); `S "CACHING"; `P "Remote fetches are cached for 1 hour to improve performance. \ Use $(b,--refresh) to force fresh fetches from all remotes."; `S Manpage.s_examples; `P "Show diffs for all repos needing attention (uses cache):"; `Pre "monopam diff"; `P "Show diff for a specific repository:"; `Pre "monopam diff ocaml-eio"; `P "Show patches for all commits:"; `Pre "monopam diff -p"; `P "Show patch for a specific commit (from diff output):"; `Pre "monopam diff abc1234"; `P "Force fresh fetches from all remotes:"; `Pre "monopam diff --refresh"; ] in let info = Cmd.info "diff" ~doc ~man in let arg = let doc = "Repository name or commit SHA. If a 7+ character hex string, shows \ the patch for that commit. Otherwise filters to that repository. \ If not specified, shows diffs for all repos needing attention." in Arg.(value & pos 0 (some string) None & info [] ~docv:"REPO|SHA" ~doc) in let refresh_arg = let doc = "Force fresh fetches from all remotes, ignoring the 1-hour cache." in Arg.(value & flag & info [ "refresh"; "r" ] ~doc) in let patch_arg = let doc = "Show full patch content for each commit." in Arg.(value & flag & info [ "patch"; "p" ] ~doc) in let run arg refresh patch () = Eio_main.run @@ fun env -> with_config env @@ fun config -> with_verse_config env @@ fun verse_config -> let fs = Eio.Stdenv.fs env in let proc = Eio.Stdenv.process_mgr env in (* Check if arg looks like a commit SHA *) match arg with | Some sha when Monopam.is_commit_sha sha -> (* Show patch for specific commit *) (match Monopam.diff_show_commit ~proc ~fs ~config ~verse_config ~sha ~refresh () with | Some info -> let short_hash = String.sub info.commit_hash 0 (min 7 (String.length info.commit_hash)) in Fmt.pr "%a %s (%s/%s)@.@.%s@." Fmt.(styled `Yellow string) short_hash info.commit_subject info.commit_repo info.commit_handle info.commit_patch; `Ok () | None -> Fmt.epr "Commit %s not found in any verse diff@." sha; `Error (false, "commit not found")) | repo -> let result = Monopam.diff ~proc ~fs ~config ~verse_config ?repo ~refresh ~patch () in Fmt.pr "%a" (Monopam.pp_diff_result ~show_patch:patch) result; `Ok () in Cmd.v info Term.(ret (const run $ arg $ refresh_arg $ patch_arg $ logging_term)) (* Pull command - pull from verse members *) let pull_cmd = let doc = "Pull commits from a verse member's forks" in let man = [ `S Manpage.s_description; `P "Pulls commits from a verse member's forks into your local checkouts. \ This merges their changes into your checkout branches, making them \ ready to be synced to the monorepo via $(b,monopam sync)."; `S "WORKFLOW"; `P "The typical workflow for incorporating changes from collaborators:"; `I ("1.", "$(b,monopam diff) - See what changes are available"); `I ("2.", "$(b,monopam pull ) - Pull changes from a collaborator"); `I ("3.", "$(b,monopam sync) - Sync changes into your monorepo"); `S "MERGING BEHAVIOR"; `P "When you're behind (they have commits you don't):"; `I ("Fast-forward", "If your branch has no new commits, a fast-forward merge is used."); `P "When branches have diverged (both have new commits):"; `I ("Merge commit", "A merge commit is created to combine the histories."); `S Manpage.s_examples; `P "Pull all changes from a verse member:"; `Pre "monopam pull avsm.bsky.social"; `P "Pull changes for a specific repository:"; `Pre "monopam pull avsm.bsky.social eio"; `P "Force fresh fetches before pulling:"; `Pre "monopam pull --refresh avsm.bsky.social"; ] in let info = Cmd.info "pull" ~doc ~man in let handle_arg = let doc = "The verse member handle to pull from (e.g., avsm.bsky.social)." in Arg.(required & pos 0 (some string) None & info [] ~docv:"HANDLE" ~doc) in let repo_arg = let doc = "Optional repository to pull from. If not specified, pulls from all \ repositories where the handle has commits you don't have." in Arg.(value & pos 1 (some string) None & info [] ~docv:"REPO" ~doc) in let refresh_arg = let doc = "Force fresh fetches from all remotes, ignoring the 1-hour cache." in Arg.(value & flag & info [ "refresh"; "r" ] ~doc) in let run handle repo refresh () = Eio_main.run @@ fun env -> with_config env @@ fun config -> with_verse_config env @@ fun verse_config -> let fs = Eio.Stdenv.fs env in let proc = Eio.Stdenv.process_mgr env in match Monopam.pull_from_handle ~proc ~fs ~config ~verse_config ~handle ?repo ~refresh () with | Ok result -> Fmt.pr "%a" Monopam.pp_handle_pull_result result; if result.repos_failed <> [] then `Error (false, "some repos failed to pull") else if result.repos_pulled = [] then begin Fmt.pr "Nothing to pull from %s@." handle; `Ok () end else begin Fmt.pr "@.Run $(b,monopam sync) to merge changes into your monorepo.@."; `Ok () end | Error e -> Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e; `Error (false, "pull failed") in Cmd.v info Term.(ret (const run $ handle_arg $ repo_arg $ refresh_arg $ logging_term)) (* Cherrypick command *) let cherrypick_cmd = let doc = "Cherry-pick a specific commit from a verse member's fork" in let man = [ `S Manpage.s_description; `P "Applies a specific commit from a verse member's fork to your local checkout. \ Use $(b,monopam diff) to see available commits and their hashes."; `S "WORKFLOW"; `P "The typical workflow for cherry-picking specific commits:"; `I ("1.", "$(b,monopam diff) - See available commits with their hashes"); `I ("2.", "$(b,monopam diff ) - View the full patch for a commit"); `I ("3.", "$(b,monopam cherrypick ) - Apply that commit"); `I ("4.", "$(b,monopam sync) - Sync changes into your monorepo"); `S Manpage.s_examples; `P "Cherry-pick a commit:"; `Pre "monopam cherrypick abc1234"; `P "View a commit's patch first, then cherry-pick:"; `Pre "monopam diff abc1234"; `Pre "monopam cherrypick abc1234"; ] in let info = Cmd.info "cherrypick" ~doc ~man in let sha_arg = let doc = "The commit SHA (or prefix) to cherry-pick. Must be at least 7 characters." in Arg.(required & pos 0 (some string) None & info [] ~docv:"SHA" ~doc) in let refresh_arg = let doc = "Force fresh fetches from all remotes, ignoring the 1-hour cache." in Arg.(value & flag & info [ "refresh"; "r" ] ~doc) in let run sha refresh () = Eio_main.run @@ fun env -> with_config env @@ fun config -> with_verse_config env @@ fun verse_config -> let fs = Eio.Stdenv.fs env in let proc = Eio.Stdenv.process_mgr env in match Monopam.cherrypick ~proc ~fs ~config ~verse_config ~sha ~refresh () with | Ok result -> Fmt.pr "%a" Monopam.pp_cherrypick_result result; Fmt.pr "Run $(b,monopam sync) to merge changes into your monorepo.@."; `Ok () | Error e -> Fmt.epr "Error: %a@." Monopam.pp_error_with_hint e; `Error (false, "cherrypick failed") in Cmd.v info Term.(ret (const run $ sha_arg $ refresh_arg $ logging_term)) (* Doctor command *) let doctor_cmd = let doc = "Claude-powered workspace health analysis" in let man = [ `S Manpage.s_description; `P "Analyzes your workspace health and provides actionable \ recommendations. Uses Claude AI to analyze commits from verse \ collaborators, categorizing them by type, priority, and risk level."; `S "WHAT IT DOES"; `P "The doctor command:"; `I ("1.", "Syncs the workspace (unless $(b,--no-sync) is specified)"); `I ("2.", "Checks local sync status (monorepo vs checkouts)"); `I ("3.", "Checks remote sync status (checkouts vs upstream)"); `I ("4.", "Analyzes fork relationships with verse members"); `I ("5.", "Uses Claude to categorize and prioritize their commits"); `I ("6.", "Generates actionable recommendations"); `P "The status output from $(b,monopam status) is automatically included \ in the prompt sent to Claude, so Claude doesn't need to run it \ separately."; `S "OUTPUT FORMATS"; `P "By default, outputs human-readable text with colors."; `P "Use $(b,--json) for JSON output suitable for tooling."; `S Manpage.s_examples; `P "Run full analysis (syncs first):"; `Pre "monopam doctor"; `P "Run analysis without syncing first:"; `Pre "monopam doctor --no-sync"; `P "Analyze a specific repo:"; `Pre "monopam doctor eio"; `P "Output as JSON:"; `Pre "monopam doctor --json"; ] in let info = Cmd.info "doctor" ~doc ~man in let json_arg = let doc = "Output as JSON instead of formatted text." in Arg.(value & flag & info [ "json" ] ~doc) in let no_sync_arg = let doc = "Skip running sync before analysis." in Arg.(value & flag & info [ "no-sync" ] ~doc) in let run package json no_sync () = Eio_main.run @@ fun env -> with_config env @@ fun config -> with_verse_config env @@ fun verse_config -> let fs = Eio.Stdenv.fs env in let proc = Eio.Stdenv.process_mgr env in let clock = Eio.Stdenv.clock env in (* Run sync before analysis unless --no-sync is specified *) if not no_sync then begin Fmt.pr "Syncing workspace before analysis...@."; match Monopam.sync ~proc ~fs ~config ?package () with | Ok _summary -> () | Error e -> Fmt.pr "Warning: sync failed: %a@." Monopam.pp_error_with_hint e; Fmt.pr "Continuing with analysis...@." end; let report = Monopam.Doctor.analyze ~proc ~fs ~config ~verse_config ~clock ?package ~no_sync () in if json then print_endline (Monopam.Doctor.to_json report) else Fmt.pr "%a@." Monopam.Doctor.pp_report report; `Ok () in Cmd.v info Term.(ret (const run $ package_arg $ json_arg $ no_sync_arg $ logging_term)) (* Feature commands *) let feature_name_arg = let doc = "Feature name (used for both worktree directory and branch)" in Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc) let feature_add_cmd = let doc = "Create a new feature worktree for parallel development" in let man = [ `S Manpage.s_description; `P "Creates a git worktree at $(b,root/work/) with a new branch named \ $(b,). This allows parallel development on separate branches, \ useful for having multiple Claude instances working on different features."; `S "HOW IT WORKS"; `P "The command:"; `I ("1.", "Creates the $(b,work/) directory if it doesn't exist"); `I ("2.", "Creates a git worktree at $(b,work/)"); `I ("3.", "Checks out a new branch named $(b,)"); `S Manpage.s_examples; `P "Create a feature worktree:"; `Pre "monopam feature add my-feature\n\ cd work/my-feature\n\ # Now you can work here independently"; `P "Have multiple Claudes work in parallel:"; `Pre "# Terminal 1\n\ monopam feature add auth-system\n\ cd work/auth-system && claude\n\n\ # Terminal 2\n\ monopam feature add api-refactor\n\ cd work/api-refactor && claude"; ] in let info = Cmd.info "add" ~doc ~man in let run name () = Eio_main.run @@ fun env -> with_verse_config env @@ fun verse_config -> let fs = Eio.Stdenv.fs env in let proc = Eio.Stdenv.process_mgr env in match Monopam.Feature.add ~proc ~fs ~config:verse_config ~name () with | Ok entry -> Fmt.pr "Created feature worktree '%s' at %a@." entry.name Fpath.pp entry.path; Fmt.pr "@.To start working:@."; Fmt.pr " cd %a@." Fpath.pp entry.path; `Ok () | Error e -> Fmt.epr "Error: %a@." Monopam.Feature.pp_error_with_hint e; `Error (false, "feature add failed") in Cmd.v info Term.(ret (const run $ feature_name_arg $ logging_term)) let feature_remove_cmd = let doc = "Remove a feature worktree" in let man = [ `S Manpage.s_description; `P "Removes the git worktree at $(b,root/work/). The branch is not \ deleted, so you can recreate the worktree later if needed."; `S "OPTIONS"; `I ("--force", "Remove even if there are uncommitted changes"); `S Manpage.s_examples; `P "Remove a completed feature worktree:"; `Pre "monopam feature remove my-feature"; `P "Force remove with uncommitted changes:"; `Pre "monopam feature remove my-feature --force"; ] in let info = Cmd.info "remove" ~doc ~man in let force_arg = let doc = "Remove even if there are uncommitted changes." in Arg.(value & flag & info [ "force"; "f" ] ~doc) in let run name force () = Eio_main.run @@ fun env -> with_verse_config env @@ fun verse_config -> let fs = Eio.Stdenv.fs env in let proc = Eio.Stdenv.process_mgr env in match Monopam.Feature.remove ~proc ~fs ~config:verse_config ~name ~force () with | Ok () -> Fmt.pr "Removed feature worktree '%s'.@." name; `Ok () | Error e -> Fmt.epr "Error: %a@." Monopam.Feature.pp_error_with_hint e; `Error (false, "feature remove failed") in Cmd.v info Term.(ret (const run $ feature_name_arg $ force_arg $ logging_term)) let feature_list_cmd = let doc = "List all feature worktrees" in let man = [ `S Manpage.s_description; `P "Lists all git worktrees in the $(b,root/work/) directory."; `S Manpage.s_examples; `Pre "monopam feature list"; ] in let info = Cmd.info "list" ~doc ~man in let run () = Eio_main.run @@ fun env -> with_verse_config env @@ fun verse_config -> let fs = Eio.Stdenv.fs env in let proc = Eio.Stdenv.process_mgr env in let entries = Monopam.Feature.list ~proc ~fs ~config:verse_config () in if entries = [] then Fmt.pr "No feature worktrees found.@." else begin Fmt.pr "Feature worktrees:@."; List.iter (fun entry -> Fmt.pr " %s -> %a (branch: %s)@." entry.Monopam.Feature.name Fpath.pp entry.Monopam.Feature.path entry.Monopam.Feature.branch ) entries end; `Ok () in Cmd.v info Term.(ret (const run $ logging_term)) let feature_cmd = let doc = "Manage feature worktrees for parallel development" in let man = [ `S Manpage.s_description; `P "Feature worktrees allow parallel development on separate branches of \ the monorepo. This is useful for having multiple Claude instances \ working on different features simultaneously."; `S "WORKSPACE STRUCTURE"; `P "Feature worktrees are created in the $(b,work/) directory:"; `Pre "root/\n\ ├── mono/ # Main monorepo\n\ ├── work/\n\ │ ├── feature-a/ # Worktree on branch 'feature-a'\n\ │ └── feature-b/ # Worktree on branch 'feature-b'\n\ └── ..."; `S "COMMANDS"; `I ("add ", "Create a new feature worktree"); `I ("remove ", "Remove a feature worktree"); `I ("list", "List all feature worktrees"); `S "WORKFLOW"; `P "Typical workflow for parallel development:"; `Pre "# Create feature worktrees\n\ monopam feature add auth-system\n\ monopam feature add api-cleanup\n\n\ # Work in each worktree independently\n\ cd work/auth-system && claude\n\ cd work/api-cleanup && claude\n\n\ # When done, merge branches back to main\n\ cd mono\n\ git merge auth-system\n\ git merge api-cleanup\n\n\ # Clean up worktrees\n\ monopam feature remove auth-system\n\ monopam feature remove api-cleanup"; ] in let info = Cmd.info "feature" ~doc ~man in Cmd.group info [ feature_add_cmd; feature_remove_cmd; feature_list_cmd ] (* Devcontainer command *) let default_devcontainer_url = "https://raw.githubusercontent.com/avsm/claude-ocaml-devcontainer/refs/heads/main/.devcontainer/devcontainer.json" let devcontainer_cmd = let doc = "Setup and enter a devcontainer environment" in let man = [ `S Manpage.s_description; `P "Creates and enters a devcontainer environment for OCaml development \ with monopam and Claude. If the target directory doesn't have a \ .devcontainer configuration, it will be created automatically."; `P "This is the recommended way to get started with monopam. The \ devcontainer provides a consistent environment with OCaml, opam, \ and all required tools pre-installed."; `S "WHAT IT DOES"; `P "For a new directory (no .devcontainer/):"; `I ("1.", "Creates the target directory if needed"); `I ("2.", "Creates .devcontainer/ subdirectory"); `I ("3.", "Downloads devcontainer.json from the template repository"); `I ("4.", "Builds and starts the devcontainer"); `I ("5.", "Opens an interactive shell inside the container"); `P "For an existing directory with .devcontainer/:"; `I ("1.", "Starts the devcontainer if not running"); `I ("2.", "Opens an interactive shell inside the container"); `S Manpage.s_options; `P "Use $(b,--url) to specify a custom devcontainer.json URL if you want \ to use a different base configuration."; `S Manpage.s_examples; `P "Create a new devcontainer workspace:"; `Pre "monopam devcontainer ~/my-ocaml-project"; `P "Enter an existing devcontainer:"; `Pre "monopam devcontainer ~/my-ocaml-project"; `P "Use a custom devcontainer.json:"; `Pre "monopam devcontainer --url https://example.com/devcontainer.json ~/project"; ] in let info = Cmd.info "devcontainer" ~doc ~man in let path_arg = let doc = "Target directory for the devcontainer workspace." in Arg.(required & pos 0 (some string) None & info [] ~docv:"PATH" ~doc) in let url_arg = let doc = "URL to fetch devcontainer.json from. Defaults to the claude-ocaml-devcontainer template." in Arg.(value & opt string default_devcontainer_url & info ["url"] ~docv:"URL" ~doc) in let run path url () = (* Resolve to absolute path *) let abs_path = if Filename.is_relative path then Filename.concat (Sys.getcwd ()) path else path in let devcontainer_dir = Filename.concat abs_path ".devcontainer" in let devcontainer_json = Filename.concat devcontainer_dir "devcontainer.json" in (* Check if .devcontainer exists *) let needs_init = not (Sys.file_exists devcontainer_dir && Sys.is_directory devcontainer_dir) in if needs_init then begin Fmt.pr "Initializing devcontainer in %s...@." abs_path; (* Create directories *) (try Unix.mkdir abs_path 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ()); (try Unix.mkdir devcontainer_dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> ()); (* Fetch devcontainer.json using curl *) Fmt.pr "Fetching devcontainer.json from %s...@." url; let curl_cmd = Printf.sprintf "curl -fsSL '%s' -o '%s'" url devcontainer_json in let ret = Sys.command curl_cmd in if ret <> 0 then begin Fmt.epr "Error: Failed to fetch devcontainer.json (curl exit code %d)@." ret; exit 1 end; Fmt.pr "Created %s@." devcontainer_json; (* Build and start the devcontainer *) Fmt.pr "Building devcontainer (this may take a while on first run)...@."; let up_cmd = Printf.sprintf "npx @devcontainers/cli up --workspace-folder '%s' --remove-existing-container" abs_path in let ret = Sys.command up_cmd in if ret <> 0 then begin Fmt.epr "Error: Failed to start devcontainer (exit code %d)@." ret; exit 1 end end; (* Exec into the devcontainer *) Fmt.pr "Entering devcontainer...@."; let exec_cmd = Printf.sprintf "npx @devcontainers/cli exec --workspace-folder '%s' bash -l" abs_path in let ret = Sys.command exec_cmd in if ret <> 0 then `Error (false, Printf.sprintf "devcontainer exec failed with code %d" ret) else `Ok () in Cmd.v info Term.(ret (const run $ path_arg $ url_arg $ logging_term)) (* Confirmation prompt *) let confirm prompt = Printf.printf "%s [y/N] %!" prompt; match In_channel.(input_line stdin) with | Some s -> String.lowercase_ascii (String.trim s) = "y" | None -> false (* Prompt for optional string input *) let prompt_string prompt = Printf.printf "%s %!" prompt; match In_channel.(input_line stdin) with | Some s -> let s = String.trim s in if s = "" then None else Some s | None -> None (* Fork command *) let fork_cmd = let doc = "Fork a monorepo subtree into its own repository" in let man = [ `S Manpage.s_description; `P "Splits a monorepo subdirectory into its own git repository and \ establishes a proper subtree relationship. This creates src// \ with the extracted history, then re-adds mono// as a subtree."; `S "FORK MODES"; `P "The fork command handles two scenarios:"; `I ("Subtree with history", "For subtrees added via $(b,git subtree add) or \ $(b,monopam join), the command uses $(b,git subtree split) to extract \ the full commit history into the new repository."); `I ("Fresh package", "For packages created directly in mono/ without subtree \ history, the command copies the files and creates an initial commit. \ This is useful for new packages you've developed locally."); `S "WHAT IT DOES"; `P "The fork command performs a complete workflow in one step:"; `I ("1.", "Analyzes mono// to detect fork mode"); `I ("2.", "Builds an action plan and shows discovery details"); `I ("3.", "Prompts for confirmation (use $(b,--yes) to skip)"); `I ("4.", "Creates a new git repo at src//"); `I ("5.", "Extracts history (subtree split) or copies files (fresh package)"); `I ("6.", "Removes mono// from git and commits"); `I ("7.", "Re-adds mono// as a proper subtree from src//"); `I ("8.", "Updates sources.toml with $(b,origin = \"fork\")"); `S "AFTER FORKING"; `P "After forking, the subtree relationship is fully established:"; `I ("-", "mono// is now a proper git subtree of src//"); `I ("-", "$(b,monopam sync) will push/pull changes correctly"); `I ("-", "No need for manual $(b,git rm) or $(b,monopam rejoin)"); `P "To push to a remote:"; `Pre "cd src/ && git push -u origin main"; `S Manpage.s_examples; `P "Fork a subtree with local-only repo:"; `Pre "monopam fork my-lib"; `P "Fork with a remote push URL:"; `Pre "monopam fork my-lib git@github.com:me/my-lib.git"; `P "Preview what would be done:"; `Pre "monopam fork my-lib --dry-run"; `P "Fork without confirmation:"; `Pre "monopam fork my-lib --yes"; ] in let info = Cmd.info "fork" ~doc ~man in let name_arg = let doc = "Name of the subtree to fork (directory name under mono/)" in Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc) in let url_arg = let doc = "Optional remote URL to add as 'origin' for pushing" in Arg.(value & pos 1 (some string) None & info [] ~docv:"URL" ~doc) in let dry_run_arg = let doc = "Show what would be done without making changes" in Arg.(value & flag & info [ "dry-run"; "n" ] ~doc) in let yes_arg = let doc = "Assume yes to all prompts (for automation)" in Arg.(value & flag & info [ "yes"; "y" ] ~doc) in let run name url dry_run yes () = Eio_main.run @@ fun env -> with_verse_config env @@ fun config -> let fs = Eio.Stdenv.fs env in let proc = Eio.Stdenv.process_mgr env in (* Get URL: use provided, or try to derive from dune-project, or prompt *) let url = match url with | Some _ -> url | None -> (* Try to get default from dune-project *) let mono_path = Monopam.Config.mono_path config in let subtree_path = Fpath.(mono_path / name) in let knot = Monopam.Config.knot config in let suggested = Monopam.Fork_join.suggest_push_url ~fs ~knot subtree_path in if yes || dry_run then suggested (* Use suggested or None without prompting *) else begin match suggested with | Some default_url -> Fmt.pr "Remote push URL [%s]: %!" default_url; (match prompt_string "" with | None -> Some default_url (* User pressed enter, use default *) | Some entered -> Some entered) | None -> Fmt.pr "Remote push URL (leave empty to skip): %!"; prompt_string "" end in (* Build the plan *) match Monopam.Fork_join.plan_fork ~proc ~fs ~config ~name ?push_url:url ~dry_run () with | Error e -> Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e; `Error (false, "fork failed") | Ok plan -> (* Print discovery and actions *) Fmt.pr "Analyzing fork request for '%s'...@.@." name; Fmt.pr "Discovery:@.%a@." Monopam.Fork_join.pp_discovery plan.discovery; (match url with | Some u -> Fmt.pr " Remote URL: %s@." u | None -> ()); Fmt.pr "@.Actions to perform:@."; List.iteri (fun i action -> Fmt.pr " %d. %a@." (i + 1) Monopam.Fork_join.pp_action action ) plan.actions; Fmt.pr "@."; (* Prompt for confirmation unless --yes or --dry-run *) let proceed = if dry_run then begin Fmt.pr "(dry-run mode - no changes will be made)@."; true end else if yes then true else confirm "Proceed?" in if not proceed then begin Fmt.pr "Cancelled.@."; `Ok () end else begin (* Execute the plan *) match Monopam.Fork_join.execute_fork_plan ~proc ~fs plan with | Ok result -> if not dry_run then begin Fmt.pr "%a@." Monopam.Fork_join.pp_fork_result result; Fmt.pr "@.Next steps:@."; Fmt.pr " 1. Review the new repo: cd src/%s@." result.name; match url with | Some _ -> Fmt.pr " 2. Push to remote: git push -u origin main@." | None -> Fmt.pr " 2. Add a remote: git remote add origin @." end; `Ok () | Error e -> Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e; `Error (false, "fork failed") end in Cmd.v info Term.(ret (const run $ name_arg $ url_arg $ dry_run_arg $ yes_arg $ logging_term)) (* Join command *) let join_cmd = let doc = "Bring an external repository into the monorepo" in let man = [ `S Manpage.s_description; `P "Clones an external git repository and adds it as a subtree in the \ monorepo. This is the inverse of $(b,monopam fork)."; `S "JOIN MODES"; `P "The join command handles multiple scenarios:"; `I ("URL join", "Clone from a git URL and add as subtree (default)."); `I ("Local directory join", "Import from a local filesystem path. If the \ path is a git repo, uses it directly. If not, initializes a new repo."); `I ("Verse join", "Join from a verse member's repository using $(b,--from)."); `S "WHAT IT DOES"; `P "The join command:"; `I ("1.", "Analyzes the source (URL or local path)"); `I ("2.", "Builds an action plan and shows discovery details"); `I ("3.", "Prompts for confirmation (use $(b,--yes) to skip)"); `I ("4.", "Clones/copies the repository to src//"); `I ("5.", "Uses $(b,git subtree add) to bring into monorepo"); `I ("6.", "Updates sources.toml with $(b,origin = \"join\")"); `S "JOINING FROM VERSE"; `P "To join a package from a verse member, use $(b,--from):"; `Pre "monopam join --from avsm.bsky.social --url git@github.com:me/cohttp.git cohttp"; `P "This will:"; `I ("-", "Look up the package in their opam-repo"); `I ("-", "Find all packages from the same git repository"); `I ("-", "Create opam entries pointing to your fork"); `I ("-", "Clone and add the subtree"); `S "AFTER JOINING"; `P "After joining, work with the subtree normally:"; `I ("1.", "Make changes in mono//"); `I ("2.", "Commit in mono/"); `I ("3.", "Run $(b,monopam sync --remote) to push upstream"); `S Manpage.s_examples; `P "Join a repository:"; `Pre "monopam join https://github.com/someone/some-lib"; `P "Join from a local directory:"; `Pre "monopam join /path/to/local/repo --as my-lib"; `P "Join with explicit name using --url:"; `Pre "monopam join --url https://tangled.org/handle/sortal sortal"; `P "Join with a custom name using --as:"; `Pre "monopam join https://github.com/someone/some-lib --as my-lib"; `P "Join with upstream tracking (for forks):"; `Pre "monopam join https://github.com/me/cohttp --upstream https://github.com/mirage/cohttp"; `P "Join from a verse member:"; `Pre "monopam join cohttp --from avsm.bsky.social --url git@github.com:me/cohttp.git"; `P "Preview what would be done:"; `Pre "monopam join https://github.com/someone/lib --dry-run"; `P "Join without confirmation:"; `Pre "monopam join https://github.com/someone/lib --yes"; ] in let info = Cmd.info "join" ~doc ~man in let url_or_pkg_arg = let doc = "Git URL, local path, or subtree name (when using --url)" in Arg.(required & pos 0 (some string) None & info [] ~docv:"SOURCE" ~doc) in let as_arg = let doc = "Override subtree directory name" in Arg.(value & opt (some string) None & info [ "as" ] ~docv:"NAME" ~doc) in let upstream_arg = let doc = "Original upstream URL (for tracking forks)" in Arg.(value & opt (some string) None & info [ "upstream" ] ~docv:"URL" ~doc) in let from_arg = let doc = "Verse member handle to join from (requires --url)" in Arg.(value & opt (some string) None & info [ "from" ] ~docv:"HANDLE" ~doc) in let fork_url_arg = let doc = "Git URL to clone from (makes positional arg the subtree name)" in Arg.(value & opt (some string) None & info [ "url" ] ~docv:"URL" ~doc) in let dry_run_arg = let doc = "Show what would be done without making changes" in Arg.(value & flag & info [ "dry-run"; "n" ] ~doc) in let yes_arg = let doc = "Assume yes to all prompts (for automation)" in Arg.(value & flag & info [ "yes"; "y" ] ~doc) in let run url_or_pkg as_name upstream from fork_url dry_run yes () = Eio_main.run @@ fun env -> with_verse_config env @@ fun config -> let fs = Eio.Stdenv.fs env in let proc = Eio.Stdenv.process_mgr env in match from with | Some handle -> (* Join from verse member - requires --url for your fork *) (* Uses legacy API as it involves verse-specific operations *) (match fork_url with | None -> Fmt.epr "Error: --url is required when using --from@."; `Error (false, "--url required") | Some fork_url -> match Monopam.Fork_join.join_from_verse ~proc ~fs ~config ~verse_config:config ~package:url_or_pkg ~handle ~fork_url ~dry_run () with | Ok result -> if dry_run then begin Fmt.pr "Would join '%s' from %s:@." result.name (Option.value ~default:"verse" result.from_handle); Fmt.pr " Source: %s@." result.source_url; Option.iter (fun u -> Fmt.pr " Upstream: %s@." u) result.upstream_url; Fmt.pr " Packages: %a@." Fmt.(list ~sep:(any ", ") string) result.packages_added end else begin Fmt.pr "%a@." Monopam.Fork_join.pp_join_result result; Fmt.pr "@.Next steps:@."; Fmt.pr " 1. Commit the opam changes: cd opam-repo && git add -A && git commit@."; Fmt.pr " 2. Run $(b,monopam sync) to synchronize@." end; `Ok () | Error e -> Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e; `Error (false, "join failed")) | None -> (* Normal join from URL or local path - use plan-based workflow *) let source = match fork_url with Some u -> u | None -> url_or_pkg in let name = match fork_url with Some _ -> Some url_or_pkg | None -> as_name in (* Build the plan *) match Monopam.Fork_join.plan_join ~proc ~fs ~config ~source ?name ?upstream ~dry_run () with | Error e -> Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e; `Error (false, "join failed") | Ok plan -> (* Print discovery and actions *) let is_local = Monopam.Fork_join.is_local_path source in Fmt.pr "Analyzing join request...@.@."; Fmt.pr "Discovery:@."; Fmt.pr " Source: %s (%s)@." source (if is_local then "local directory" else "remote URL"); Fmt.pr "%a" Monopam.Fork_join.pp_discovery plan.discovery; Fmt.pr "@.Actions to perform:@."; List.iteri (fun i action -> Fmt.pr " %d. %a@." (i + 1) Monopam.Fork_join.pp_action action ) plan.actions; Fmt.pr "@."; (* Prompt for confirmation unless --yes or --dry-run *) let proceed = if dry_run then begin Fmt.pr "(dry-run mode - no changes will be made)@."; true end else if yes then true else confirm "Proceed?" in if not proceed then begin Fmt.pr "Cancelled.@."; `Ok () end else begin (* Execute the plan *) match Monopam.Fork_join.execute_join_plan ~proc ~fs plan with | Ok result -> if not dry_run then begin Fmt.pr "%a@." Monopam.Fork_join.pp_join_result result; Fmt.pr "@.Next steps:@."; Fmt.pr " 1. Run $(b,monopam sync) to synchronize@." end; `Ok () | Error e -> Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e; `Error (false, "join failed") end in Cmd.v info Term.(ret (const run $ url_or_pkg_arg $ as_arg $ upstream_arg $ from_arg $ fork_url_arg $ dry_run_arg $ yes_arg $ logging_term)) (* Rejoin command *) let rejoin_cmd = let doc = "Add a source checkout back into the monorepo as a subtree" in let man = [ `S Manpage.s_description; `P "Adds an existing src// repository back into mono// as a \ subtree. This is useful after forking a package and removing it from \ the monorepo with $(b,git rm)."; `S "WORKFLOW"; `P "Typical workflow for removing and re-adding a package:"; `I ("1.", "Fork the package: $(b,monopam fork my-lib)"); `I ("2.", "Remove from monorepo: $(b,git rm -r mono/my-lib && git commit)"); `I ("3.", "Work on it in src/my-lib/"); `I ("4.", "Re-add to monorepo: $(b,monopam rejoin my-lib)"); `S "REQUIREMENTS"; `P "For rejoin to work:"; `I ("-", "src// must exist and be a git repository"); `I ("-", "mono// must NOT exist (was removed)"); `S "WHAT IT DOES"; `P "The rejoin command:"; `I ("1.", "Verifies src// exists and is a git repo"); `I ("2.", "Verifies mono// does not exist"); `I ("3.", "Prompts for confirmation (use $(b,--yes) to skip)"); `I ("4.", "Uses $(b,git subtree add) to bring src// into mono//"); `S Manpage.s_examples; `P "Re-add a package from src/:"; `Pre "monopam rejoin my-lib"; `P "Preview what would be done:"; `Pre "monopam rejoin my-lib --dry-run"; `P "Rejoin without confirmation:"; `Pre "monopam rejoin my-lib --yes"; ] in let info = Cmd.info "rejoin" ~doc ~man in let name_arg = let doc = "Name of the subtree to rejoin (directory name under src/)" in Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc) in let dry_run_arg = let doc = "Show what would be done without making changes" in Arg.(value & flag & info [ "dry-run"; "n" ] ~doc) in let yes_arg = let doc = "Assume yes to all prompts (for automation)" in Arg.(value & flag & info [ "yes"; "y" ] ~doc) in let run name dry_run yes () = Eio_main.run @@ fun env -> with_verse_config env @@ fun config -> let fs = Eio.Stdenv.fs env in let proc = Eio.Stdenv.process_mgr env in (* Build the plan *) match Monopam.Fork_join.plan_rejoin ~proc ~fs ~config ~name ~dry_run () with | Error e -> Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e; `Error (false, "rejoin failed") | Ok plan -> (* Print discovery and actions *) Fmt.pr "Analyzing rejoin request for '%s'...@.@." name; Fmt.pr "Discovery:@.%a@." Monopam.Fork_join.pp_discovery plan.discovery; Fmt.pr "@.Actions to perform:@."; List.iteri (fun i action -> Fmt.pr " %d. %a@." (i + 1) Monopam.Fork_join.pp_action action ) plan.actions; Fmt.pr "@."; (* Prompt for confirmation unless --yes or --dry-run *) let proceed = if dry_run then begin Fmt.pr "(dry-run mode - no changes will be made)@."; true end else if yes then true else confirm "Proceed?" in if not proceed then begin Fmt.pr "Cancelled.@."; `Ok () end else begin (* Execute the plan *) match Monopam.Fork_join.execute_join_plan ~proc ~fs plan with | Ok result -> if not dry_run then begin Fmt.pr "%a@." Monopam.Fork_join.pp_join_result result; Fmt.pr "@.Next steps:@."; Fmt.pr " 1. Commit the changes: git add -A && git commit@."; Fmt.pr " 2. Run $(b,monopam sync) to synchronize@." end; `Ok () | Error e -> Fmt.epr "Error: %a@." Monopam.Fork_join.pp_error_with_hint e; `Error (false, "rejoin failed") end in Cmd.v info Term.(ret (const run $ name_arg $ dry_run_arg $ yes_arg $ logging_term)) (* Site command *) let site_cmd = let doc = "Generate a static HTML site representing the monoverse map" in let man = [ `S Manpage.s_description; `P "Generates a static index.html file that maps the monoverse, showing all \ verse members, their packages, and the relationships between them."; `S "OUTPUT"; `P "The generated site includes:"; `I ("Members", "All verse members with links to their monorepo and opam repos"); `I ("Summary", "Overview of common libraries and member-specific packages"); `I ("Repository Details", "Each shared repo with packages and fork status"); `S "FORK STATUS"; `P "Use $(b,--status) to include fork relationship information:"; `I ("+N", "You are N commits ahead of them"); `I ("-N", "They are N commits ahead of you"); `I ("+N/-M", "Diverged: you have N new, they have M new"); `I ("sync", "Same commit"); `S "DESIGN"; `P "The HTML is designed to be:"; `I ("-", "Simple and clean with a 10pt font"); `I ("-", "Responsive and compact"); `I ("-", "External links marked with icon and teal color"); `S Manpage.s_examples; `P "Generate site to default location (mono/index.html):"; `Pre "monopam site"; `P "Generate site with fork status (slower, fetches remotes):"; `Pre "monopam site --status"; `P "Generate site to custom location:"; `Pre "monopam site -o /var/www/monoverse/index.html"; `P "Print HTML to stdout:"; `Pre "monopam site --stdout"; ] in let info = Cmd.info "site" ~doc ~man in let output_arg = let doc = "Output file path. Defaults to mono/index.html." in Arg.(value & opt (some string) None & info [ "o"; "output" ] ~docv:"FILE" ~doc) in let stdout_arg = let doc = "Print HTML to stdout instead of writing to file." in Arg.(value & flag & info [ "stdout" ] ~doc) in let status_arg = let doc = "Include fork status (ahead/behind) for each repository. \ This fetches from remotes and may be slower." in Arg.(value & flag & info [ "status"; "s" ] ~doc) in let run output to_stdout with_status () = Eio_main.run @@ fun env -> with_config env @@ fun monopam_config -> with_verse_config env @@ fun verse_config -> let fs = Eio.Stdenv.fs env in let proc = Eio.Stdenv.process_mgr env in (* Pull/clone registry to get latest metadata *) Fmt.pr "Syncing registry...@."; let registry = match Monopam.Verse_registry.clone_or_pull ~proc ~fs:(fs :> _ Eio.Path.t) ~config:verse_config () with | Ok r -> r | Error msg -> Fmt.epr "Warning: Could not sync registry: %s@." msg; Monopam.Verse_registry.{ name = "opamverse"; description = None; members = [] } in (* Compute forks if --status is requested *) let forks = if with_status then begin Fmt.pr "Computing fork status...@."; Some (Monopam.Forks.compute ~proc ~fs:(fs :> _ Eio.Path.t) ~verse_config ~monopam_config ()) end else None in if to_stdout then begin let html = Monopam.Site.generate ~fs:(fs :> _ Eio.Path.t) ~config:verse_config ?forks ~registry () in print_string html; `Ok () end else begin let output_path = match output with | Some p -> ( match Fpath.of_string p with | Ok fp -> fp | Error (`Msg _) -> Fpath.v p) | None -> Fpath.(Monopam.Verse_config.mono_path verse_config / "index.html") in match Monopam.Site.write ~fs:(fs :> _ Eio.Path.t) ~config:verse_config ?forks ~registry ~output_path () with | Ok () -> Fmt.pr "Site generated: %a@." Fpath.pp output_path; `Ok () | Error msg -> Fmt.epr "Error: %s@." msg; `Error (false, "site generation failed") end in Cmd.v info Term.(ret (const run $ output_arg $ stdout_arg $ status_arg $ logging_term)) (* Main command group *) let main_cmd = let doc = "Manage opam overlay with git subtree monorepo" in let man = [ `S Manpage.s_description; `P "Monopam synchronizes packages between an opam overlay repository, \ individual git checkouts, and a monorepo using git subtrees."; `P "Monopam is designed to run inside a devcontainer that provides a \ consistent OCaml development environment with all required tools \ pre-installed."; `S "QUICK START"; `P "Start by creating a devcontainer workspace:"; `Pre "monopam devcontainer ~/tangled"; `P "Inside the devcontainer, initialize your workspace:"; `Pre "cd ~/tangled\n\ monopam init --handle yourname.bsky.social\n\ cd mono"; `P "Daily workflow:"; `Pre "cd ~/tangled/mono\n\ monopam sync # sync local and remote (most common)\n\ # ... make edits ...\n\ git add -A && git commit # commit your changes\n\ monopam sync --remote # sync and push to upstream"; `S "DIRECTORY STRUCTURE"; `P "Monopam manages three directory trees:"; `I ( "mono/", "The monorepo combining all packages as git subtrees. This is where \ you make changes." ); `I ( "src/", "Individual git checkouts of each unique repository. Used for review \ and manual operations." ); `I ( "opam-repo/", "The opam overlay repository containing package metadata." ); `S "WORKFLOW"; `P "The recommended workflow uses $(b,sync) as the primary command:"; `I ( "1. monopam sync", "Synchronize your monorepo with all upstream repos. This both \ exports your local changes to checkouts AND pulls remote changes." ); `I ("2. Edit code", "Make changes in the mono/ directory"); `I ("3. git commit", "Commit your changes in mono/"); `I ( "4. monopam sync --remote", "Sync again, including pushing to upstream git remotes" ); `P "For finer control over the sync phases:"; `I ( "monopam sync --skip-pull", "Export monorepo changes to checkouts only (skip fetching remotes)" ); `I ( "monopam sync --skip-push", "Pull remote changes only (skip exporting local changes)" ); `S "CHECKING STATUS"; `P "Run $(b,monopam status) to see the state of all repositories:"; `I ("local:+N", "Your monorepo is N commits ahead of the checkout"); `I ("local:-N", "The checkout is N commits ahead of your monorepo"); `I ("local:sync", "Trees differ but need syncing (run $(b,monopam sync))"); `I ("remote:+N", "Your checkout is N commits ahead of upstream"); `I ("remote:-N", "Upstream is N commits ahead (run $(b,monopam sync))"); `S "COMMON TASKS"; `I ("Start fresh", "monopam init --handle you.bsky.social"); `I ("Check status", "monopam status"); `I ("Sync everything", "monopam sync"); `I ("Sync and push upstream", "monopam sync --remote"); `I ("Sync one package", "monopam sync "); `S "CONFIGURATION"; `P "Run $(b,monopam init --handle ) to create a workspace. \ Configuration is stored in ~/.config/monopam/opamverse.toml."; `P "Workspace structure:"; `Pre "root/\n\ ├── mono/ # Your monorepo (work here)\n\ ├── src/ # Git checkouts (for review)\n\ ├── opam-repo/ # Opam overlay\n\ └── verse/ # Other members' monorepos"; `S "TROUBLESHOOTING"; `I ( "\"Dirty packages\" error", "You have uncommitted changes. Run: cd mono && git status" ); `I ( "\"local:sync\" in status", "The monorepo and checkout are out of sync. Run: monopam sync" ); `I ( "Merge conflicts", "Resolve conflicts in mono/, commit, then run: monopam sync" ); `S Manpage.s_commands; `P "Use $(b,monopam COMMAND --help) for help on a specific command."; ] in let info = Cmd.info "monopam" ~version:"%%VERSION%%" ~doc ~man in Cmd.group info [ init_cmd; status_cmd; diff_cmd; pull_cmd; cherrypick_cmd; sync_cmd; changes_cmd; opam_cmd; doctor_cmd; verse_cmd; feature_cmd; fork_cmd; join_cmd; rejoin_cmd; devcontainer_cmd; site_cmd ] let () = exit (Cmd.eval main_cmd)