A monorepo management tool for the agentic ages

Add opam init and promote commands for local package management

- unpac opam init: Create new local opam packages from scratch
- Generates scaffold with dune-project, lib/dune, and module files
- Recorded in config with url='local' for identification
- Comprehensive documentation for AI agent usage

- unpac opam promote: Graduate projects to vendored dependencies
- Copies project content to opam/vendor/<name> branches
- Enables code reuse between projects
- Original project remains unchanged

- Enhanced opam command documentation:
- Three package sources: External, Local, Promoted
- Workflow guides for each source type
- AI agent usage examples

- Added audit operation types: Opam_init, Opam_promote

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+363 -7
+355 -7
bin/main.ml
··· 883 883 let info = Cmd.info "remove" ~doc in 884 884 Cmd.v info Term.(const run $ logging_term $ pkg_arg) 885 885 886 + (* Opam init command - create a new local opam package *) 887 + let opam_init_cmd = 888 + let doc = "Create a new local opam package (no upstream repository)." in 889 + let man = [ 890 + `S Manpage.s_description; 891 + `P "Creates a new opam package that originates locally rather than from \ 892 + an external repository. This is useful for:"; 893 + `I ("New libraries", "Starting a new OCaml library from scratch"); 894 + `I ("Internal packages", "Creating packages that will never be published"); 895 + `I ("Agent-created packages", "AI agents can create new dependencies on-demand"); 896 + `P "The package is created with a minimal scaffold including dune-project \ 897 + and a .opam file. It uses the standard three-tier branch model but \ 898 + with no upstream branch (url='local' in config)."; 899 + `S "PACKAGE STRUCTURE"; 900 + `P "The created package will have:"; 901 + `Pre " vendor/opam/<name>/ 902 + dune-project # Dune project file 903 + <name>.opam # Opam package file 904 + lib/ 905 + dune # Library build rules 906 + <name>.ml # Main module (empty) 907 + <name>.mli # Interface file (empty)"; 908 + `S Manpage.s_examples; 909 + `P "Create a new local library:"; 910 + `Pre " unpac opam init mylib 911 + unpac opam merge mylib myproject"; 912 + `P "Create with description:"; 913 + `Pre " unpac opam init mylib --synopsis 'My utility library'"; 914 + `S "LIFECYCLE"; 915 + `P "Local packages can later be published by:"; 916 + `Pre " 1. Push the opam/patches/<name> branch to a git repository 917 + 2. Update config with: unpac opam set-upstream <name> <url> 918 + 3. Submit to opam-repository if desired"; 919 + `S "SEE ALSO"; 920 + `P "unpac-opam-promote(1) for graduating projects to dependencies."; 921 + ] in 922 + let name_arg = 923 + let doc = "Name for the new package. Should be a valid opam package name \ 924 + (lowercase, alphanumeric, hyphens allowed)." in 925 + Arg.(required & pos 0 (some string) None & info [] ~docv:"NAME" ~doc) 926 + in 927 + let synopsis_arg = 928 + let doc = "One-line synopsis for the package." in 929 + Arg.(value & opt string "A local opam package" & info ["synopsis"; "s"] ~docv:"TEXT" ~doc) 930 + in 931 + let run () name synopsis = 932 + with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 933 + with_audit ~proc_mgr ~root ~operation_type:Unpac.Audit.Opam_init ~args:[name] @@ fun _ctx -> 934 + let git = Unpac.Worktree.git_dir root in 935 + let config = load_config root in 936 + 937 + (* Validate package name *) 938 + if String.length name = 0 then begin 939 + Format.eprintf "Error: Package name cannot be empty@."; 940 + exit 1 941 + end; 942 + let valid_char c = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c = '-' || c = '_' in 943 + if not (String.for_all valid_char name) then begin 944 + Format.eprintf "Error: Package name must be lowercase alphanumeric (hyphens/underscores allowed)@."; 945 + exit 1 946 + end; 947 + 948 + (* Check if already exists *) 949 + let packages = Unpac_opam.Opam.list_packages ~proc_mgr ~root in 950 + if List.mem name packages then begin 951 + Format.eprintf "Package '%s' already exists@." name; 952 + exit 1 953 + end; 954 + 955 + (* Create an orphan branch for vendor *) 956 + let vendor_branch = Unpac_opam.Opam.vendor_branch name in 957 + let patches_branch = Unpac_opam.Opam.patches_branch name in 958 + let vendor_path = "vendor/opam/" ^ name in 959 + 960 + (* Create orphan branch with initial content *) 961 + Unpac.Git.checkout_orphan ~proc_mgr ~cwd:git vendor_branch; 962 + 963 + (* Remove any existing index content *) 964 + Unpac.Git.rm_cached_rf ~proc_mgr ~cwd:git; 965 + 966 + (* Create scaffold files in a temporary worktree *) 967 + let wt_path = Unpac.Worktree.path root (Unpac.Worktree.Opam_vendor name) in 968 + Unpac.Worktree.ensure ~proc_mgr root (Unpac.Worktree.Opam_vendor name); 969 + 970 + (* Create directory structure *) 971 + let pkg_dir = Eio.Path.(wt_path / vendor_path) in 972 + let lib_dir = Eio.Path.(pkg_dir / "lib") in 973 + Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 lib_dir; 974 + 975 + (* Create dune-project *) 976 + let dune_project = Printf.sprintf {|(lang dune 3.0) 977 + (name %s) 978 + (generate_opam_files true) 979 + (source (uri "local")) 980 + (authors "Local") 981 + (maintainers "Local") 982 + (package 983 + (name %s) 984 + (synopsis "%s") 985 + (depends 986 + (ocaml (>= 4.14)))) 987 + |} name name synopsis in 988 + Eio.Path.save ~create:(`Or_truncate 0o644) 989 + Eio.Path.(pkg_dir / "dune-project") dune_project; 990 + 991 + (* Create lib/dune *) 992 + let lib_dune = Printf.sprintf {|(library 993 + (name %s) 994 + (public_name %s)) 995 + |} (String.map (fun c -> if c = '-' then '_' else c) name) name in 996 + Eio.Path.save ~create:(`Or_truncate 0o644) 997 + Eio.Path.(lib_dir / "dune") lib_dune; 998 + 999 + (* Create lib/<name>.ml *) 1000 + let ml_file = Printf.sprintf {|(* %s - A local opam package *) 1001 + 1002 + (** This module was created by [unpac opam init]. 1003 + Add your implementation here. *) 1004 + |} name in 1005 + let ml_name = String.map (fun c -> if c = '-' then '_' else c) name in 1006 + Eio.Path.save ~create:(`Or_truncate 0o644) 1007 + Eio.Path.(lib_dir / (ml_name ^ ".ml")) ml_file; 1008 + 1009 + (* Create lib/<name>.mli *) 1010 + let mli_file = Printf.sprintf {|(* %s - A local opam package *) 1011 + 1012 + (** This module was created by [unpac opam init]. 1013 + Define your interface here. *) 1014 + |} name in 1015 + Eio.Path.save ~create:(`Or_truncate 0o644) 1016 + Eio.Path.(lib_dir / (ml_name ^ ".mli")) mli_file; 1017 + 1018 + (* Commit the scaffold *) 1019 + Unpac.Git.add_all ~proc_mgr ~cwd:wt_path; 1020 + Unpac.Git.commit ~proc_mgr ~cwd:wt_path 1021 + ~message:(Printf.sprintf "Initialize local package %s" name); 1022 + 1023 + (* Get the commit SHA *) 1024 + let sha = Unpac.Git.current_head ~proc_mgr ~cwd:wt_path in 1025 + 1026 + (* Create patches branch from vendor *) 1027 + Unpac.Git.branch_create ~proc_mgr ~cwd:git 1028 + ~name:patches_branch ~start_point:vendor_branch; 1029 + 1030 + (* Cleanup worktree *) 1031 + Unpac.Worktree.remove ~proc_mgr root (Unpac.Worktree.Opam_vendor name); 1032 + 1033 + (* Switch back to main *) 1034 + Unpac.Git.checkout ~proc_mgr ~cwd:git "main"; 1035 + 1036 + (* Record in config with url = "local" *) 1037 + let vendored : Unpac.Config.vendored_package = { 1038 + pkg_name = name; pkg_url = "local"; pkg_branch = None 1039 + } in 1040 + let config = Unpac.Config.add_vendored_package config vendored in 1041 + save_config ~proc_mgr root config (Printf.sprintf "Add local package %s" name); 1042 + 1043 + Format.printf "Created local package %s (%s)@." name (String.sub sha 0 7); 1044 + Format.printf "@.Package structure:@."; 1045 + Format.printf " %s/@." vendor_path; 1046 + Format.printf " dune-project@."; 1047 + Format.printf " lib/dune@."; 1048 + Format.printf " lib/%s.ml@." ml_name; 1049 + Format.printf " lib/%s.mli@." ml_name; 1050 + Format.printf "@.Next steps:@."; 1051 + Format.printf " unpac opam edit %s # add code to the package@." name; 1052 + Format.printf " unpac opam merge %s <project> # use in a project@." name 1053 + in 1054 + let info = Cmd.info "init" ~doc ~man in 1055 + Cmd.v info Term.(const run $ logging_term $ name_arg $ synopsis_arg) 1056 + 1057 + (* Opam promote command - graduate a project to a vendored dependency *) 1058 + let opam_promote_cmd = 1059 + let doc = "Promote a project to a vendored opam dependency." in 1060 + let man = [ 1061 + `S Manpage.s_description; 1062 + `P "Graduates a project branch to become a vendored opam dependency that \ 1063 + other projects can use. This is the lifecycle path for code that:"; 1064 + `I ("Started as a project", "Code developed in project/<name> that should \ 1065 + become a shared library"); 1066 + `I ("Needs reuse", "A project that other projects want to depend on"); 1067 + `I ("Agent refactoring", "AI agents can extract common code into libraries"); 1068 + `P "The project's content is copied to create opam/vendor/<name> and \ 1069 + opam/patches/<name> branches. The original project remains unchanged \ 1070 + and can be deleted if no longer needed."; 1071 + `S "REQUIREMENTS"; 1072 + `P "The project directory should contain a valid dune-project file with \ 1073 + the package definition. If not present, a basic one will be created."; 1074 + `S Manpage.s_examples; 1075 + `P "Promote a project to a dependency:"; 1076 + `Pre " unpac opam promote my-utils 1077 + unpac opam merge my-utils other-project"; 1078 + `P "Promote with a different name:"; 1079 + `Pre " unpac opam promote my-app --as my-lib"; 1080 + `S "LIFECYCLE"; 1081 + `P "After promotion:"; 1082 + `Pre " 1. The new package appears in 'unpac opam list' 1083 + 2. Other projects can merge it with 'unpac opam merge' 1084 + 3. Edit with 'unpac opam edit' (changes go to patches branch) 1085 + 4. Original project can be deleted if desired"; 1086 + `S "SEE ALSO"; 1087 + `P "unpac-opam-init(1) for creating new packages from scratch."; 1088 + ] in 1089 + let project_arg = 1090 + let doc = "Name of the project to promote." in 1091 + Arg.(required & pos 0 (some string) None & info [] ~docv:"PROJECT" ~doc) 1092 + in 1093 + let as_arg = 1094 + let doc = "Name for the opam package (defaults to project name)." in 1095 + Arg.(value & opt (some string) None & info ["as"] ~docv:"NAME" ~doc) 1096 + in 1097 + let run () project pkg_name_opt = 1098 + with_root @@ fun ~env:_ ~fs:_ ~proc_mgr ~root -> 1099 + let pkg_name = match pkg_name_opt with Some n -> n | None -> project in 1100 + with_audit ~proc_mgr ~root ~operation_type:Unpac.Audit.Opam_promote ~args:[project; pkg_name] @@ fun _ctx -> 1101 + let git = Unpac.Worktree.git_dir root in 1102 + let config = load_config root in 1103 + 1104 + (* Check project exists *) 1105 + let projects = Unpac.Worktree.list_projects ~proc_mgr root in 1106 + if not (List.mem project projects) then begin 1107 + Format.eprintf "Project '%s' does not exist@." project; 1108 + Format.eprintf "@.Available projects:@."; 1109 + List.iter (Format.eprintf " %s@.") projects; 1110 + exit 1 1111 + end; 1112 + 1113 + (* Check package doesn't already exist *) 1114 + let packages = Unpac_opam.Opam.list_packages ~proc_mgr ~root in 1115 + if List.mem pkg_name packages then begin 1116 + Format.eprintf "Package '%s' already exists@." pkg_name; 1117 + exit 1 1118 + end; 1119 + 1120 + let vendor_branch = Unpac_opam.Opam.vendor_branch pkg_name in 1121 + let patches_branch = Unpac_opam.Opam.patches_branch pkg_name in 1122 + let vendor_path = "vendor/opam/" ^ pkg_name in 1123 + 1124 + (* Create orphan branch for vendor *) 1125 + Unpac.Git.checkout_orphan ~proc_mgr ~cwd:git vendor_branch; 1126 + Unpac.Git.rm_cached_rf ~proc_mgr ~cwd:git; 1127 + 1128 + (* Create vendor worktree *) 1129 + Unpac.Worktree.ensure ~proc_mgr root (Unpac.Worktree.Opam_vendor pkg_name); 1130 + let vendor_wt = Unpac.Worktree.path root (Unpac.Worktree.Opam_vendor pkg_name) in 1131 + 1132 + (* Get project worktree or create temporary one *) 1133 + let project_wt = Unpac.Worktree.path root (Unpac.Worktree.Project project) in 1134 + let created_project_wt = not (Sys.file_exists (snd project_wt)) in 1135 + if created_project_wt then 1136 + Unpac.Worktree.ensure ~proc_mgr root (Unpac.Worktree.Project project); 1137 + 1138 + (* Create target directory *) 1139 + let pkg_dir = Eio.Path.(vendor_wt / vendor_path) in 1140 + Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 pkg_dir; 1141 + 1142 + (* Copy project content to vendor path *) 1143 + let rec copy_dir src dst = 1144 + Eio.Path.read_dir src |> List.iter (fun name -> 1145 + if name <> ".git" then begin 1146 + let src_path = Eio.Path.(src / name) in 1147 + let dst_path = Eio.Path.(dst / name) in 1148 + if Eio.Path.is_directory src_path then begin 1149 + Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 dst_path; 1150 + copy_dir src_path dst_path 1151 + end else begin 1152 + let content = Eio.Path.load src_path in 1153 + Eio.Path.save ~create:(`Or_truncate 0o644) dst_path content 1154 + end 1155 + end 1156 + ) 1157 + in 1158 + copy_dir project_wt pkg_dir; 1159 + 1160 + (* Commit *) 1161 + Unpac.Git.add_all ~proc_mgr ~cwd:vendor_wt; 1162 + Unpac.Git.commit ~proc_mgr ~cwd:vendor_wt 1163 + ~message:(Printf.sprintf "Promote project %s to package %s" project pkg_name); 1164 + 1165 + (* Get SHA *) 1166 + let sha = Unpac.Git.current_head ~proc_mgr ~cwd:vendor_wt in 1167 + 1168 + (* Create patches branch from vendor *) 1169 + Unpac.Git.branch_create ~proc_mgr ~cwd:git 1170 + ~name:patches_branch ~start_point:vendor_branch; 1171 + 1172 + (* Cleanup *) 1173 + Unpac.Worktree.remove ~proc_mgr root (Unpac.Worktree.Opam_vendor pkg_name); 1174 + if created_project_wt then 1175 + Unpac.Worktree.remove ~proc_mgr root (Unpac.Worktree.Project project); 1176 + 1177 + (* Switch back to main *) 1178 + Unpac.Git.checkout ~proc_mgr ~cwd:git "main"; 1179 + 1180 + (* Record in config *) 1181 + let vendored : Unpac.Config.vendored_package = { 1182 + pkg_name; pkg_url = "local"; pkg_branch = None 1183 + } in 1184 + let config = Unpac.Config.add_vendored_package config vendored in 1185 + save_config ~proc_mgr root config (Printf.sprintf "Promote project %s to package %s" project pkg_name); 1186 + 1187 + Format.printf "Promoted project %s to package %s (%s)@." project pkg_name (String.sub sha 0 7); 1188 + Format.printf "@.The package is now available as a vendored dependency.@."; 1189 + Format.printf "@.Next steps:@."; 1190 + Format.printf " unpac opam merge %s <other-project> # use in another project@." pkg_name; 1191 + Format.printf " unpac opam edit %s # make changes@." pkg_name; 1192 + if project <> pkg_name then 1193 + Format.printf " unpac project remove %s # remove original project (optional)@." project 1194 + in 1195 + let info = Cmd.info "promote" ~doc ~man in 1196 + Cmd.v info Term.(const run $ logging_term $ project_arg $ as_arg) 1197 + 886 1198 (* Opam command group *) 887 1199 let opam_cmd = 888 1200 let doc = "Opam package vendoring commands." in 889 1201 let man = [ 890 1202 `S Manpage.s_description; 891 - `P "Vendor OCaml packages from opam repositories with full git history. \ 1203 + `P "Vendor OCaml packages from opam repositories or create new local packages. \ 892 1204 Uses a three-tier branch model for conflict-free vendoring:"; 893 - `I ("opam/upstream/<pkg>", "Tracks the original repository state"); 1205 + `I ("opam/upstream/<pkg>", "Tracks the original repository state (empty for local packages)"); 894 1206 `I ("opam/vendor/<pkg>", "Clean snapshot used as merge base"); 895 1207 `I ("opam/patches/<pkg>", "Local modifications on top of vendor"); 896 - `S "TYPICAL WORKFLOW"; 1208 + `S "PACKAGE SOURCES"; 1209 + `P "Packages can come from three sources:"; 1210 + `I ("External (unpac opam add)", "Vendor from opam repository or git URL. \ 1211 + Has upstream tracking for updates."); 1212 + `I ("Local (unpac opam init)", "Create a new package from scratch. \ 1213 + No upstream, recorded as url='local' in config."); 1214 + `I ("Promoted (unpac opam promote)", "Graduate a project to a dependency. \ 1215 + Allows code reuse between projects."); 1216 + `S "TYPICAL WORKFLOW - External Packages"; 897 1217 `P "1. Configure an opam repository:"; 898 1218 `Pre " unpac opam repo add default /path/to/opam-repository"; 899 1219 `P "2. Set the OCaml compiler version for dependency solving:"; ··· 905 1225 unpac opam merge mypackage myapp --solve"; 906 1226 `P "5. Build in the project directory:"; 907 1227 `Pre " cd project/myapp && dune build"; 1228 + `S "TYPICAL WORKFLOW - Local Packages"; 1229 + `P "1. Create a new local package:"; 1230 + `Pre " unpac opam init mylib --synopsis 'My utility library'"; 1231 + `P "2. Add code to the package:"; 1232 + `Pre " unpac opam edit mylib 1233 + # edit files in vendor/opam/mylib-patches/ 1234 + git add -A && git commit -m 'implement mylib' 1235 + unpac opam done mylib"; 1236 + `P "3. Use in a project:"; 1237 + `Pre " unpac opam merge mylib myproject"; 1238 + `S "TYPICAL WORKFLOW - Promoting Projects"; 1239 + `P "When a project should become a shared library:"; 1240 + `Pre " unpac opam promote myproject --as mylib 1241 + unpac opam merge mylib other-project"; 908 1242 `S "MAKING LOCAL CHANGES"; 909 1243 `P "1. Open package for editing (creates worktrees):"; 910 1244 `Pre " unpac opam edit mypackage"; ··· 917 1251 `P "4. View your changes:"; 918 1252 `Pre " unpac opam diff mypackage"; 919 1253 `S "UPDATING FROM UPSTREAM"; 920 - `P "1. Fetch and apply upstream changes:"; 921 - `Pre " unpac opam update mypackage"; 922 - `P "2. Re-merge into your project:"; 923 - `Pre " unpac opam merge mypackage myapp"; 1254 + `P "For packages with external upstreams (not local packages):"; 1255 + `Pre " unpac opam update mypackage 1256 + unpac opam merge mypackage myapp"; 1257 + `S "FOR AI AGENTS"; 1258 + `P "When an agent needs to create a new dependency:"; 1259 + `Pre " # Option 1: Create from scratch 1260 + unpac opam init new-lib --synopsis 'Agent-created library' 1261 + unpac opam edit new-lib 1262 + # ... add implementation ... 1263 + unpac opam done new-lib 1264 + unpac opam merge new-lib target-project"; 1265 + `Pre " # Option 2: Extract from existing project 1266 + unpac opam promote existing-project --as new-lib 1267 + unpac opam merge new-lib other-project"; 1268 + `P "Local packages have url='local' in unpac.toml and can be identified with:"; 1269 + `Pre " unpac opam info <package> # shows URL: local"; 924 1270 `S "COMMANDS"; 925 1271 ] in 926 1272 let info = Cmd.info "opam" ~doc ~man in ··· 928 1274 opam_repo_cmd; 929 1275 opam_config_cmd; 930 1276 opam_add_cmd; 1277 + opam_init_cmd; 1278 + opam_promote_cmd; 931 1279 opam_list_cmd; 932 1280 opam_edit_cmd; 933 1281 opam_done_cmd;
+6
lib/audit.ml
··· 31 31 | Init 32 32 | Project_new 33 33 | Opam_add 34 + | Opam_init 35 + | Opam_promote 34 36 | Opam_update 35 37 | Opam_merge 36 38 | Opam_edit ··· 128 130 | Init -> "init" 129 131 | Project_new -> "project.new" 130 132 | Opam_add -> "opam.add" 133 + | Opam_init -> "opam.init" 134 + | Opam_promote -> "opam.promote" 131 135 | Opam_update -> "opam.update" 132 136 | Opam_merge -> "opam.merge" 133 137 | Opam_edit -> "opam.edit" ··· 144 148 | "init" -> Init 145 149 | "project.new" -> Project_new 146 150 | "opam.add" -> Opam_add 151 + | "opam.init" -> Opam_init 152 + | "opam.promote" -> Opam_promote 147 153 | "opam.update" -> Opam_update 148 154 | "opam.merge" -> Opam_merge 149 155 | "opam.edit" -> Opam_edit
+2
lib/audit.mli
··· 43 43 | Init 44 44 | Project_new 45 45 | Opam_add 46 + | Opam_init 47 + | Opam_promote 46 48 | Opam_update 47 49 | Opam_merge 48 50 | Opam_edit