@roulabs/mx 1.11.0 → 2.1.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/bin/mx.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/main.ts
4
- import { readFileSync as readFileSync2 } from "fs";
4
+ import { readFileSync as readFileSync3 } from "fs";
5
5
  import * as path9 from "path";
6
6
  import { fileURLToPath as fileURLToPath2 } from "url";
7
7
 
@@ -49,6 +49,56 @@ function realpath(p) {
49
49
  }
50
50
  }
51
51
 
52
+ // ../../packages/core/src/git.ts
53
+ import { execFileSync } from "child_process";
54
+ function git(args, opts = {}) {
55
+ try {
56
+ const out = execFileSync("git", args, {
57
+ encoding: "utf8",
58
+ stdio: ["ignore", "pipe", "pipe"],
59
+ ...opts
60
+ });
61
+ return (out == null ? "" : String(out)).trim();
62
+ } catch (e) {
63
+ const err = e;
64
+ const msg = (err.stderr ?? err.stdout ?? err.message ?? "").toString().trim();
65
+ throw new MxError(`git ${args.join(" ")} failed: ${msg}`, "GIT");
66
+ }
67
+ }
68
+ function gitQuiet(args, opts = {}) {
69
+ try {
70
+ const out = execFileSync("git", args, {
71
+ encoding: "utf8",
72
+ stdio: ["ignore", "pipe", "ignore"],
73
+ ...opts
74
+ });
75
+ return (out == null ? "" : String(out)).trim();
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+ function currentBranch(repoPath2) {
81
+ return gitQuiet(["-C", repoPath2, "rev-parse", "--abbrev-ref", "HEAD"]) ?? "(detached)";
82
+ }
83
+ function remoteUrl(repoPath2) {
84
+ return gitQuiet(["-C", repoPath2, "remote", "get-url", "origin"]) ?? gitQuiet(["-C", repoPath2, "config", "--get", "remote.origin.url"]) ?? null;
85
+ }
86
+ function branchExists(repoPath2, branch) {
87
+ return gitQuiet(["-C", repoPath2, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]) !== null;
88
+ }
89
+ function isDirty(worktreePath2) {
90
+ const s = gitQuiet(["-C", worktreePath2, "status", "--porcelain"]);
91
+ return s == null ? false : s.length > 0;
92
+ }
93
+ function remoteBranchList(repoPath2) {
94
+ const out = gitQuiet(["-C", repoPath2, "for-each-ref", "--format=%(refname)", "refs/remotes/origin"]);
95
+ if (!out) return [];
96
+ return out.split("\n").map((s) => s.trim()).filter((s) => s && !s.endsWith("/HEAD")).map((s) => s.replace(/^refs\/remotes\/origin\//, ""));
97
+ }
98
+ function resolveBase(repoPath2, base) {
99
+ return gitQuiet(["-C", repoPath2, "rev-parse", "--verify", `${base}^{commit}`]) ?? gitQuiet(["-C", repoPath2, "rev-parse", "--verify", `origin/${base}^{commit}`]) ?? null;
100
+ }
101
+
52
102
  // ../../packages/core/src/json.ts
53
103
  import * as fs2 from "fs";
54
104
  function readJson(file) {
@@ -87,6 +137,20 @@ function stampContextIndex(targetDir, templatesDir2) {
87
137
  fs3.copyFileSync(src, dest);
88
138
  return dest;
89
139
  }
140
+ var REPO_SCRIPTS = ["hydrate.sh", "health.sh"];
141
+ function stampRepoScripts(containerDir, templatesDir2) {
142
+ const created = [];
143
+ for (const name of REPO_SCRIPTS) {
144
+ const dest = path2.join(containerDir, name);
145
+ if (exists(dest)) continue;
146
+ const src = path2.join(templatesDir2, "repo", name);
147
+ if (!exists(src)) throw new MxError(`missing template: ${src}`, "NO_TEMPLATE");
148
+ fs3.copyFileSync(src, dest);
149
+ fs3.chmodSync(dest, 493);
150
+ created.push(dest);
151
+ }
152
+ return created;
153
+ }
90
154
 
91
155
  // ../../packages/core/src/runtime.ts
92
156
  var DEFAULT_RUNTIME = path3.join(os.homedir(), "mx");
@@ -96,9 +160,40 @@ function defaultRuntime() {
96
160
  var reposDir = (root) => path3.join(root, "repos");
97
161
  var worksDir = (root) => path3.join(root, "works");
98
162
  var repoPath = (root, name) => path3.join(reposDir(root), name);
163
+ var repoGitDir = (root, name) => path3.join(repoPath(root, name), "git");
164
+ var repoHydrateScript = (root, name) => path3.join(repoPath(root, name), "hydrate.sh");
165
+ var repoHealthScript = (root, name) => path3.join(repoPath(root, name), "health.sh");
99
166
  var workDir = (root, name) => path3.join(worksDir(root), name);
100
167
  var workManifest = (root, name) => path3.join(workDir(root, name), "work.json");
101
168
  var workspaceFile = (root, name) => path3.join(workDir(root, name), `${name}.code-workspace`);
169
+ var worktreesDir = (root, name) => path3.join(workDir(root, name), "wt");
170
+ var worktreePath = (root, name, repo) => path3.join(worktreesDir(root, name), repo);
171
+ var RUNTIME_VERSION = 2;
172
+ var mxConfigFile = (root) => path3.join(root, "mx.json");
173
+ function readMxConfig(root) {
174
+ const f = mxConfigFile(root);
175
+ if (!exists(f)) return null;
176
+ return readJson(f);
177
+ }
178
+ function readRuntimeVersion(root) {
179
+ const cfg = readMxConfig(root);
180
+ if (!cfg) return 1;
181
+ const n = cfg.version;
182
+ if (!Number.isInteger(n) || n < 1) {
183
+ throw new MxError(`invalid version in ${mxConfigFile(root)}: ${JSON.stringify(n)}`, "BAD_VERSION");
184
+ }
185
+ return n;
186
+ }
187
+ function writeRuntimeVersion(root, version) {
188
+ const existing = readMxConfig(root) ?? {};
189
+ writeJson(mxConfigFile(root), { ...existing, version });
190
+ }
191
+ function versionMismatchMessage(actual) {
192
+ if (actual > RUNTIME_VERSION) {
193
+ return `runtime is v${actual}, newer than this mx supports (v${RUNTIME_VERSION}). Upgrade your mx CLI: \`npm i -g @roulabs/mx@latest\`.`;
194
+ }
195
+ return `runtime is v${actual} but this mx supports runtime v${RUNTIME_VERSION}. Run \`mx migrate\` to upgrade the runtime.`;
196
+ }
102
197
  function discoverRuntime(opts = {}) {
103
198
  const p = opts.runtime || process.env.MX_RUNTIME || DEFAULT_RUNTIME;
104
199
  return path3.resolve(p);
@@ -108,10 +203,82 @@ function requireRuntime(opts = {}) {
108
203
  if (!exists(path3.join(root, ".mx-root"))) {
109
204
  throw new MxError(`not an mx runtime (no .mx-root): ${root} \u2014 run \`mx init\``, "NO_RUNTIME");
110
205
  }
206
+ if (!opts.allowVersionMismatch) {
207
+ const v = readRuntimeVersion(root);
208
+ if (v !== RUNTIME_VERSION) {
209
+ throw new MxError(versionMismatchMessage(v), "RUNTIME_VERSION_MISMATCH");
210
+ }
211
+ }
111
212
  return root;
112
213
  }
113
214
  function listRepoNames(root) {
114
- return listDirs(reposDir(root)).filter((n) => isGitRepo(repoPath(root, n)));
215
+ return listDirs(reposDir(root)).filter((n) => isGitRepo(repoGitDir(root, n)));
216
+ }
217
+ function migrateRepoLayout(root) {
218
+ const migrated = [];
219
+ for (const name of listDirs(reposDir(root))) {
220
+ const container = repoPath(root, name);
221
+ const gitdir = repoGitDir(root, name);
222
+ if (isGitRepo(gitdir)) continue;
223
+ if (!isGitRepo(container)) continue;
224
+ const tmp = path3.join(reposDir(root), `.${name}.mxmig`);
225
+ if (exists(tmp)) fs4.rmSync(tmp, { recursive: true, force: true });
226
+ fs4.renameSync(container, tmp);
227
+ fs4.mkdirSync(container, { recursive: true });
228
+ fs4.renameSync(tmp, gitdir);
229
+ try {
230
+ git(["-C", gitdir, "worktree", "repair"]);
231
+ } catch {
232
+ }
233
+ migrated.push(container);
234
+ }
235
+ return migrated;
236
+ }
237
+ function migrateWorkLayout(root) {
238
+ const changed = [];
239
+ for (const name of listWorkNames(root)) {
240
+ let work;
241
+ try {
242
+ work = readWork(root, name);
243
+ } catch {
244
+ continue;
245
+ }
246
+ const wd = workDir(root, name);
247
+ const wtDir = path3.join(wd, "wt");
248
+ for (const wt of work.worktrees ?? []) {
249
+ const flat = path3.join(wd, wt.repo);
250
+ const dest = path3.join(wtDir, wt.repo);
251
+ if (exists(dest) || !exists(flat)) continue;
252
+ fs4.mkdirSync(wtDir, { recursive: true });
253
+ try {
254
+ git(["-C", repoGitDir(root, wt.repo), "worktree", "move", flat, dest]);
255
+ } catch {
256
+ fs4.renameSync(flat, dest);
257
+ try {
258
+ git(["-C", repoGitDir(root, wt.repo), "worktree", "repair", dest]);
259
+ } catch {
260
+ }
261
+ }
262
+ changed.push(dest);
263
+ }
264
+ const wsFile = workspaceFile(root, name);
265
+ if (exists(wsFile)) {
266
+ const ws = readJson(wsFile);
267
+ const repos = new Set((work.worktrees ?? []).map((w) => w.repo));
268
+ let touched = false;
269
+ for (const f of ws.folders ?? []) {
270
+ if (f.path && !f.path.startsWith("wt/") && repos.has(f.path)) {
271
+ f.path = `wt/${f.path}`;
272
+ touched = true;
273
+ }
274
+ }
275
+ if (touched) {
276
+ writeJson(wsFile, ws);
277
+ changed.push(wsFile);
278
+ }
279
+ }
280
+ }
281
+ return changed;
115
282
  }
116
283
  function listWorkNames(root) {
117
284
  return listDirs(worksDir(root)).filter((n) => exists(workManifest(root, n)));
@@ -147,7 +314,7 @@ function inferContext(root) {
147
314
  return rel.split(path3.sep);
148
315
  };
149
316
  const w = segmentsUnder(worksDir(root));
150
- if (w) return { work: w[0], repo: w[1] ?? null };
317
+ if (w) return { work: w[0], repo: w[1] === "wt" ? w[2] ?? null : null };
151
318
  const r = segmentsUnder(reposDir(root));
152
319
  if (r) return { work: null, repo: r[0] };
153
320
  return { work: null, repo: null };
@@ -155,6 +322,15 @@ function inferContext(root) {
155
322
  function initRuntime(target0, templatesDir2) {
156
323
  const target = path3.resolve(target0);
157
324
  const created = [];
325
+ if (exists(path3.join(target, ".mx-root"))) {
326
+ const v = readRuntimeVersion(target);
327
+ if (v !== RUNTIME_VERSION) {
328
+ throw new MxError(
329
+ `cannot init: existing runtime at ${target} is v${v}, but this mx supports v${RUNTIME_VERSION}. ` + (v < RUNTIME_VERSION ? "Run `mx migrate` to upgrade it." : "Upgrade your mx CLI."),
330
+ "RUNTIME_VERSION_MISMATCH"
331
+ );
332
+ }
333
+ }
158
334
  for (const d of [target, reposDir(target), worksDir(target)]) {
159
335
  if (!exists(d)) {
160
336
  fs4.mkdirSync(d, { recursive: true });
@@ -166,6 +342,10 @@ function initRuntime(target0, templatesDir2) {
166
342
  fs4.writeFileSync(marker, "");
167
343
  created.push(marker);
168
344
  }
345
+ if (!exists(mxConfigFile(target))) {
346
+ writeRuntimeVersion(target, RUNTIME_VERSION);
347
+ created.push(mxConfigFile(target));
348
+ }
169
349
  created.push(stampClaudeMd(target, templatesDir2));
170
350
  const ctxIndex = stampContextIndex(target, templatesDir2);
171
351
  if (ctxIndex) created.push(ctxIndex);
@@ -174,14 +354,55 @@ function initRuntime(target0, templatesDir2) {
174
354
  }
175
355
  function ensureWorkScaffolding(root, workName) {
176
356
  const created = [];
177
- const sessions = path3.join(workDir(root, workName), "sessions");
178
- if (!exists(sessions)) {
179
- fs4.mkdirSync(sessions, { recursive: true });
180
- created.push(sessions);
357
+ const wd = workDir(root, workName);
358
+ for (const d of ["wt", "scripts", "files", "tmp", "sessions"]) {
359
+ const p = path3.join(wd, d);
360
+ if (!exists(p)) {
361
+ fs4.mkdirSync(p, { recursive: true });
362
+ created.push(p);
363
+ }
364
+ }
365
+ const claudeMd = path3.join(wd, "CLAUDE.md");
366
+ if (!exists(claudeMd)) {
367
+ fs4.writeFileSync(claudeMd, workClaudeMd(workName));
368
+ created.push(claudeMd);
369
+ }
370
+ const settings = path3.join(wd, ".claude", "settings.json");
371
+ if (!exists(settings)) {
372
+ fs4.mkdirSync(path3.dirname(settings), { recursive: true });
373
+ fs4.writeFileSync(settings, workClaudeSettings(root));
374
+ created.push(settings);
181
375
  }
182
376
  return created;
183
377
  }
184
- function updateRuntime(root, templatesDir2) {
378
+ function workClaudeMd(name) {
379
+ return `<!--
380
+ Work-specific CLAUDE.md for "${name}".
381
+
382
+ This file loads alongside the runtime's CLAUDE.md (the mx rules) for every Claude
383
+ session started in this work folder. Put rules and context specific to THIS work
384
+ here: what you're building, conventions, gotchas, which repo is your lane, etc.
385
+ mx never overwrites this file after creating it \u2014 it's yours to edit.
386
+
387
+ Keep ad-hoc files OUT of the work root (it holds mx-native files). Use:
388
+ files/ artifacts worth keeping
389
+ tmp/ throwaway scratch \u2014 may be deleted at any time, no guarantees
390
+ scripts/ ad-hoc scripts for this work
391
+ -->
392
+ `;
393
+ }
394
+ function workClaudeSettings(root) {
395
+ const contextDir = path3.join(root, "context");
396
+ const indexPath = path3.join(contextDir, "INDEX.json");
397
+ const command = `echo '# mx context registry \u2014 open ${contextDir}/<path>.md for relevant entries:'; cat '${indexPath}' 2>/dev/null`;
398
+ const settings = {
399
+ hooks: {
400
+ SessionStart: [{ matcher: "*", hooks: [{ type: "command", command }] }]
401
+ }
402
+ };
403
+ return JSON.stringify(settings, null, 2) + "\n";
404
+ }
405
+ function syncRuntime(root, templatesDir2) {
185
406
  const updated = [];
186
407
  updated.push(stampClaudeMd(root, templatesDir2));
187
408
  const ctxIndex = stampContextIndex(root, templatesDir2);
@@ -189,97 +410,103 @@ function updateRuntime(root, templatesDir2) {
189
410
  for (const workName of listWorkNames(root)) {
190
411
  updated.push(...ensureWorkScaffolding(root, workName));
191
412
  }
413
+ for (const repo of listRepoNames(root)) {
414
+ updated.push(...stampRepoScripts(repoPath(root, repo), templatesDir2));
415
+ }
192
416
  removeStaleRuntimeReadme(root);
193
417
  return { runtime: root, updated };
194
418
  }
195
419
 
196
- // ../../packages/core/src/repos.ts
197
- import * as fs5 from "fs";
198
- import * as path4 from "path";
199
-
200
- // ../../packages/core/src/git.ts
201
- import { execFileSync } from "child_process";
202
- function git(args, opts = {}) {
203
- try {
204
- const out = execFileSync("git", args, {
205
- encoding: "utf8",
206
- stdio: ["ignore", "pipe", "pipe"],
207
- ...opts
208
- });
209
- return (out == null ? "" : String(out)).trim();
210
- } catch (e) {
211
- const err = e;
212
- const msg = (err.stderr ?? err.stdout ?? err.message ?? "").toString().trim();
213
- throw new MxError(`git ${args.join(" ")} failed: ${msg}`, "GIT");
420
+ // ../../packages/core/src/migrations.ts
421
+ var STEPS = {
422
+ 1: {
423
+ from: 1,
424
+ to: 2,
425
+ run: (root) => {
426
+ const changed = [];
427
+ changed.push(...migrateRepoLayout(root));
428
+ changed.push(...migrateWorkLayout(root));
429
+ for (const work of listWorkNames(root)) changed.push(...ensureWorkScaffolding(root, work));
430
+ writeRuntimeVersion(root, 2);
431
+ return changed;
432
+ }
214
433
  }
215
- }
216
- function gitQuiet(args, opts = {}) {
217
- try {
218
- const out = execFileSync("git", args, {
219
- encoding: "utf8",
220
- stdio: ["ignore", "pipe", "ignore"],
221
- ...opts
222
- });
223
- return (out == null ? "" : String(out)).trim();
224
- } catch {
225
- return null;
434
+ };
435
+ function migrateRuntime(root) {
436
+ const from = readRuntimeVersion(root);
437
+ if (from === RUNTIME_VERSION) {
438
+ return { from, to: RUNTIME_VERSION, applied: [], changed: [] };
226
439
  }
227
- }
228
- function currentBranch(repoPath2) {
229
- return gitQuiet(["-C", repoPath2, "rev-parse", "--abbrev-ref", "HEAD"]) ?? "(detached)";
230
- }
231
- function remoteUrl(repoPath2) {
232
- return gitQuiet(["-C", repoPath2, "remote", "get-url", "origin"]) ?? gitQuiet(["-C", repoPath2, "config", "--get", "remote.origin.url"]) ?? null;
233
- }
234
- function branchExists(repoPath2, branch) {
235
- return gitQuiet(["-C", repoPath2, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]) !== null;
236
- }
237
- function isDirty(worktreePath) {
238
- const s = gitQuiet(["-C", worktreePath, "status", "--porcelain"]);
239
- return s == null ? false : s.length > 0;
240
- }
241
- function remoteBranchList(repoPath2) {
242
- const out = gitQuiet(["-C", repoPath2, "for-each-ref", "--format=%(refname)", "refs/remotes/origin"]);
243
- if (!out) return [];
244
- return out.split("\n").map((s) => s.trim()).filter((s) => s && !s.endsWith("/HEAD")).map((s) => s.replace(/^refs\/remotes\/origin\//, ""));
245
- }
246
- function resolveBase(repoPath2, base) {
247
- return gitQuiet(["-C", repoPath2, "rev-parse", "--verify", `${base}^{commit}`]) ?? gitQuiet(["-C", repoPath2, "rev-parse", "--verify", `origin/${base}^{commit}`]) ?? null;
440
+ if (from > RUNTIME_VERSION) {
441
+ throw new MxError(
442
+ `runtime is v${from}, newer than this mx supports (v${RUNTIME_VERSION}). Upgrade your mx CLI: \`npm i -g @roulabs/mx@latest\`.`,
443
+ "CLI_TOO_OLD"
444
+ );
445
+ }
446
+ for (let v = from; v < RUNTIME_VERSION; v++) {
447
+ if (!STEPS[v]) {
448
+ throw new MxError(
449
+ `this mx has no migration from runtime v${v} to v${v + 1} \u2014 cannot upgrade v${from} \u2192 v${RUNTIME_VERSION}.`,
450
+ "NO_MIGRATION"
451
+ );
452
+ }
453
+ }
454
+ const applied = [];
455
+ const changed = [];
456
+ for (let v = from; v < RUNTIME_VERSION; v++) {
457
+ const step = STEPS[v];
458
+ changed.push(...step.run(root));
459
+ applied.push({ from: step.from, to: step.to });
460
+ }
461
+ return { from, to: RUNTIME_VERSION, applied, changed };
248
462
  }
249
463
 
250
464
  // ../../packages/core/src/repos.ts
465
+ import { execFileSync as execFileSync2 } from "child_process";
466
+ import * as fs5 from "fs";
467
+ import * as path4 from "path";
251
468
  function repoNameFromUrl(url) {
252
469
  const base = url.split("/").pop() || url;
253
470
  return base.replace(/\.git$/, "");
254
471
  }
255
472
  function repoAdd(root, url, name0) {
256
473
  const name = name0 || repoNameFromUrl(url);
257
- const dest = repoPath(root, name);
258
- if (exists(dest)) throw new MxError(`repo already exists: ${name}`, "EXISTS");
259
- git(["clone", url, dest], { stdio: ["ignore", "inherit", "inherit"] });
260
- return { name, path: dest, remote: remoteUrl(dest), branch: currentBranch(dest) };
474
+ const container = repoPath(root, name);
475
+ if (exists(container)) throw new MxError(`repo already exists: ${name}`, "EXISTS");
476
+ const gitdir = repoGitDir(root, name);
477
+ fs5.mkdirSync(container, { recursive: true });
478
+ git(["clone", url, gitdir], { stdio: ["ignore", "inherit", "inherit"] });
479
+ return { name, path: container, remote: remoteUrl(gitdir), branch: currentBranch(gitdir) };
261
480
  }
262
481
  function listReposInfo(root) {
263
482
  return listRepoNames(root).map((name) => ({
264
483
  name,
265
- branch: currentBranch(repoPath(root, name)),
266
- remote: remoteUrl(repoPath(root, name))
484
+ // path is the container; branch/remote come from the git/ clone.
485
+ path: repoPath(root, name),
486
+ branch: currentBranch(repoGitDir(root, name)),
487
+ remote: remoteUrl(repoGitDir(root, name))
267
488
  }));
268
489
  }
269
490
  function repoFetch(root, name) {
270
- const rp = repoPath(root, name);
491
+ const rp = repoGitDir(root, name);
271
492
  if (!isGitRepo(rp)) throw new MxError(`no such repo: ${name}`, "NO_REPO");
272
493
  git(["-C", rp, "fetch", "--all", "--prune", "--tags"]);
494
+ const current = currentBranch(rp);
273
495
  gitQuiet(["-C", rp, "merge", "--ff-only", "@{u}"]);
496
+ const base = originDefaultBranch(rp);
497
+ if (base && base !== current) {
498
+ gitQuiet(["-C", rp, "fetch", ".", `refs/remotes/origin/${base}:refs/heads/${base}`]);
499
+ }
274
500
  return { name, branch: currentBranch(rp), remoteBranches: remoteBranchList(rp) };
275
501
  }
276
502
  function repoInfo(root, name) {
277
- const rp = repoPath(root, name);
503
+ const rp = repoGitDir(root, name);
278
504
  if (!isGitRepo(rp)) throw new MxError(`no such repo: ${name}`, "NO_REPO");
279
505
  const usedBy = listWorkNames(root).filter((w) => findWorktree(readWork(root, w), name));
280
506
  return {
281
507
  name,
282
- path: rp,
508
+ path: repoPath(root, name),
509
+ // the container (repo home)
283
510
  branch: currentBranch(rp),
284
511
  remote: remoteUrl(rp),
285
512
  worktreesInWorks: usedBy
@@ -321,8 +548,30 @@ function lastFetched(rp) {
321
548
  if (!exists(fh)) return null;
322
549
  return fs5.statSync(fh).mtime.toISOString();
323
550
  }
551
+ function runHealthScript(root, name) {
552
+ const script = repoHealthScript(root, name);
553
+ if (!exists(script)) return null;
554
+ try {
555
+ const out = execFileSync2(script, [], {
556
+ cwd: repoGitDir(root, name),
557
+ encoding: "utf8",
558
+ stdio: ["ignore", "pipe", "ignore"],
559
+ env: {
560
+ ...process.env,
561
+ MX_RUNTIME: root,
562
+ MX_REPO: name,
563
+ MX_REPO_PATH: repoPath(root, name),
564
+ MX_GIT_DIR: repoGitDir(root, name)
565
+ }
566
+ });
567
+ const trimmed = out.trim();
568
+ return trimmed.length ? trimmed : null;
569
+ } catch {
570
+ return null;
571
+ }
572
+ }
324
573
  function repoHealth(root, name) {
325
- const rp = repoPath(root, name);
574
+ const rp = repoGitDir(root, name);
326
575
  if (!isGitRepo(rp)) throw new MxError(`no such repo: ${name}`, "NO_REPO");
327
576
  const defaultBranch = originDefaultBranch(rp);
328
577
  const current = currentBranchOrNull(rp);
@@ -355,7 +604,8 @@ function repoHealth(root, name) {
355
604
  }
356
605
  return {
357
606
  name,
358
- path: rp,
607
+ path: repoPath(root, name),
608
+ // the container (repo home)
359
609
  defaultBranch,
360
610
  currentBranch: current,
361
611
  isOnDefault,
@@ -366,15 +616,15 @@ function repoHealth(root, name) {
366
616
  lastFetchedAt,
367
617
  worktreesInWorks,
368
618
  healthy: issues.length === 0,
369
- issues
619
+ issues,
620
+ extra: runHealthScript(root, name)
370
621
  };
371
622
  }
372
623
  function listRepoHealth(root) {
373
624
  return listRepoNames(root).map((name) => repoHealth(root, name));
374
625
  }
375
626
  function repoRemove(root, name) {
376
- const rp = repoPath(root, name);
377
- if (!isGitRepo(rp)) throw new MxError(`no such repo: ${name}`, "NO_REPO");
627
+ if (!isGitRepo(repoGitDir(root, name))) throw new MxError(`no such repo: ${name}`, "NO_REPO");
378
628
  const usedBy = listWorkNames(root).filter((w) => findWorktree(readWork(root, w), name));
379
629
  if (usedBy.length) {
380
630
  throw new MxError(
@@ -382,25 +632,25 @@ function repoRemove(root, name) {
382
632
  "IN_USE"
383
633
  );
384
634
  }
385
- fs5.rmSync(rp, { recursive: true, force: true });
635
+ fs5.rmSync(repoPath(root, name), { recursive: true, force: true });
386
636
  return { name, removed: true };
387
637
  }
388
638
 
389
639
  // ../../packages/core/src/works.ts
390
640
  import * as fs6 from "fs";
391
- import * as path5 from "path";
392
641
  function addFolderToWorkspace(root, name, repo) {
393
642
  const file = workspaceFile(root, name);
394
643
  const ws = exists(file) ? readJson(file) : { folders: [], settings: {} };
395
644
  ws.folders = ws.folders ?? [];
396
- if (!ws.folders.some((f) => f.path === repo)) ws.folders.push({ name: repo, path: repo });
645
+ const rel = `wt/${repo}`;
646
+ if (!ws.folders.some((f) => f.path === rel)) ws.folders.push({ name: repo, path: rel });
397
647
  writeJson(file, ws);
398
648
  }
399
649
  function removeFolderFromWorkspace(root, name, repo) {
400
650
  const file = workspaceFile(root, name);
401
651
  if (!exists(file)) return;
402
652
  const ws = readJson(file);
403
- ws.folders = (ws.folders ?? []).filter((f) => f.path !== repo);
653
+ ws.folders = (ws.folders ?? []).filter((f) => f.path !== `wt/${repo}`);
404
654
  writeJson(file, ws);
405
655
  }
406
656
  function clearWorkspaceFolders(root, name) {
@@ -423,6 +673,7 @@ function workNew(root, name, description = "") {
423
673
  function listWorksInfo(root, opts = {}) {
424
674
  return listWorkNames(root).map((name) => ({
425
675
  ...readWork(root, name),
676
+ path: workDir(root, name),
426
677
  sessions: countSessions(root, name)
427
678
  })).filter((w) => {
428
679
  if (opts.onlyArchived) return w.isArchived === true;
@@ -446,13 +697,14 @@ function workPath(root, name) {
446
697
  }
447
698
  function worktreeAdd(root, name, repo, opts = {}) {
448
699
  const work = readWork(root, name);
449
- const rp = repoPath(root, repo);
700
+ const rp = repoGitDir(root, repo);
450
701
  if (!isGitRepo(rp)) throw new MxError(`no such repo: ${repo}`, "NO_REPO");
451
702
  if (findWorktree(work, repo)) {
452
703
  throw new MxError(`work "${name}" already has worktree for ${repo}`, "EXISTS");
453
704
  }
454
705
  const branch = opts.branch || name;
455
- const dest = path5.join(workDir(root, name), repo);
706
+ const dest = worktreePath(root, name, repo);
707
+ fs6.mkdirSync(worktreesDir(root, name), { recursive: true });
456
708
  if (branchExists(rp, branch)) {
457
709
  git(["-C", rp, "worktree", "add", dest, branch]);
458
710
  } else {
@@ -479,11 +731,11 @@ function worktreeRemove(root, name, repo) {
479
731
  const work = readWork(root, name);
480
732
  const wt = findWorktree(work, repo);
481
733
  if (!wt) throw new MxError(`work "${name}" has no worktree for ${repo}`, "NO_WORKTREE");
482
- const dest = path5.join(workDir(root, name), repo);
734
+ const dest = worktreePath(root, name, repo);
483
735
  if (exists(dest) && isDirty(dest)) {
484
736
  throw new MxError(`worktree ${repo} has uncommitted changes \u2014 commit or discard them first`, "DIRTY");
485
737
  }
486
- git(["-C", repoPath(root, repo), "worktree", "remove", dest]);
738
+ git(["-C", repoGitDir(root, repo), "worktree", "remove", dest]);
487
739
  work.worktrees = work.worktrees.filter((w) => w.repo !== repo);
488
740
  writeWork(root, work);
489
741
  removeFolderFromWorkspace(root, name, repo);
@@ -499,7 +751,7 @@ function workDestroy(root, name, opts = {}) {
499
751
  const work = readWork(root, name);
500
752
  const dirty = [];
501
753
  for (const wt of work.worktrees ?? []) {
502
- const dest = path5.join(workDir(root, name), wt.repo);
754
+ const dest = worktreePath(root, name, wt.repo);
503
755
  if (exists(dest) && isDirty(dest)) dirty.push(wt.repo);
504
756
  }
505
757
  if (dirty.length) {
@@ -510,8 +762,8 @@ function workDestroy(root, name, opts = {}) {
510
762
  }
511
763
  const removed = [];
512
764
  for (const wt of work.worktrees ?? []) {
513
- const dest = path5.join(workDir(root, name), wt.repo);
514
- if (exists(dest)) git(["-C", repoPath(root, wt.repo), "worktree", "remove", dest]);
765
+ const dest = worktreePath(root, name, wt.repo);
766
+ if (exists(dest)) git(["-C", repoGitDir(root, wt.repo), "worktree", "remove", dest]);
515
767
  removed.push(wt.repo);
516
768
  }
517
769
  fs6.rmSync(workDir(root, name), { recursive: true, force: true });
@@ -524,7 +776,7 @@ function archiveWork(root, name) {
524
776
  }
525
777
  const dirty = [];
526
778
  for (const wt of work.worktrees ?? []) {
527
- const dest = path5.join(workDir(root, name), wt.repo);
779
+ const dest = worktreePath(root, name, wt.repo);
528
780
  if (exists(dest) && isDirty(dest)) dirty.push(wt.repo);
529
781
  }
530
782
  if (dirty.length) {
@@ -535,8 +787,8 @@ function archiveWork(root, name) {
535
787
  }
536
788
  const removed = [];
537
789
  for (const wt of work.worktrees ?? []) {
538
- const dest = path5.join(workDir(root, name), wt.repo);
539
- if (exists(dest)) git(["-C", repoPath(root, wt.repo), "worktree", "remove", dest]);
790
+ const dest = worktreePath(root, name, wt.repo);
791
+ if (exists(dest)) git(["-C", repoGitDir(root, wt.repo), "worktree", "remove", dest]);
540
792
  removed.push(wt.repo);
541
793
  }
542
794
  clearWorkspaceFolders(root, name);
@@ -558,7 +810,7 @@ function unarchiveWork(root, name, overrides = {}) {
558
810
  }));
559
811
  const missing = [];
560
812
  for (const d of desired) {
561
- const rp = repoPath(root, d.repo);
813
+ const rp = repoGitDir(root, d.repo);
562
814
  if (!isGitRepo(rp)) {
563
815
  throw new MxError(`pristine clone missing for repo: ${d.repo}`, "NO_REPO");
564
816
  }
@@ -574,8 +826,9 @@ function unarchiveWork(root, name, overrides = {}) {
574
826
  }
575
827
  const restored = [];
576
828
  for (const d of desired) {
577
- const dest = path5.join(workDir(root, name), d.repo);
578
- git(["-C", repoPath(root, d.repo), "worktree", "add", dest, d.branch]);
829
+ const dest = worktreePath(root, name, d.repo);
830
+ fs6.mkdirSync(worktreesDir(root, name), { recursive: true });
831
+ git(["-C", repoGitDir(root, d.repo), "worktree", "add", dest, d.branch]);
579
832
  addFolderToWorkspace(root, name, d.repo);
580
833
  restored.push({ repo: d.repo, branch: d.branch, path: dest, ports: d.ports });
581
834
  }
@@ -654,9 +907,9 @@ function portList(root, name) {
654
907
  }
655
908
 
656
909
  // ../../packages/core/src/status.ts
657
- import * as path6 from "path";
910
+ import * as path5 from "path";
658
911
  function countContextEntries(root) {
659
- const indexPath = path6.join(root, "context", "INDEX.json");
912
+ const indexPath = path5.join(root, "context", "INDEX.json");
660
913
  if (!exists(indexPath)) return 0;
661
914
  try {
662
915
  const data = readJson(indexPath);
@@ -671,6 +924,7 @@ function statusRuntime(root, opts = {}) {
671
924
  const works = opts.onlyArchived ? all.filter((w) => w.isArchived === true) : opts.includeArchived ? all : all.filter((w) => w.isArchived !== true);
672
925
  return {
673
926
  runtime: root,
927
+ version: readRuntimeVersion(root),
674
928
  context: { entries: countContextEntries(root) },
675
929
  repos: listReposInfo(root),
676
930
  works,
@@ -696,7 +950,9 @@ function parseArgs(argv) {
696
950
  force: false,
697
951
  yes: false,
698
952
  all: false,
699
- archived: false
953
+ archived: false,
954
+ open: false,
955
+ noHydrate: false
700
956
  };
701
957
  for (let i = 0; i < argv.length; i++) {
702
958
  const a = argv[i];
@@ -714,6 +970,10 @@ function parseArgs(argv) {
714
970
  flags.all = true;
715
971
  } else if (a === "--archived") {
716
972
  flags.archived = true;
973
+ } else if (a === "--open" || a === "-o") {
974
+ flags.open = true;
975
+ } else if (a === "--no-hydrate") {
976
+ flags.noHydrate = true;
717
977
  } else if (a.startsWith("--") && a.includes("=")) {
718
978
  const eq = a.indexOf("=");
719
979
  const key = VALUE_FLAGS[a.slice(0, eq)];
@@ -730,6 +990,7 @@ function parseArgs(argv) {
730
990
 
731
991
  // src/output.ts
732
992
  import { spawnSync } from "child_process";
993
+ import * as os2 from "os";
733
994
  var USE_STYLE = process.stdout.isTTY && !process.env.NO_COLOR;
734
995
  function wrap(s, code) {
735
996
  return USE_STYLE ? `\x1B[${code}m${s}\x1B[0m` : s;
@@ -746,6 +1007,11 @@ function check() {
746
1007
  function warn() {
747
1008
  return "\u26A0";
748
1009
  }
1010
+ function tildify(p) {
1011
+ const home = os2.homedir();
1012
+ if (p === home) return "~";
1013
+ return p.startsWith(home + "/") ? "~" + p.slice(home.length) : p;
1014
+ }
749
1015
  function confirmYesNo(prompt) {
750
1016
  if (!process.stdin.isTTY) return false;
751
1017
  process.stdout.write(prompt);
@@ -790,28 +1056,34 @@ var HELP = `mx \u2014 control panel for the mx runtime
790
1056
 
791
1057
  Global:
792
1058
  mx init [path] scaffold/adopt a runtime (default ~/mx)
793
- mx status [--all] [--porcelain] show runtime, repos, works, ports (active only by default; --all to include archived; aliases: mx s, mx st)
794
- mx update re-sync runtime from current templates + backfill structural scaffolding
1059
+ mx info [--all] [--porcelain] show runtime version, repos, works, ports (active only by default; --all to include archived; alias: mx i)
1060
+ mx sync re-stamp runtime files (CLAUDE.md, scaffolding) from current templates \u2014 same-major, non-breaking
1061
+ mx update self-update the mx CLI within its major (npm i -g); flags a newer major if one exists
1062
+ mx migrate upgrade an older-version runtime to the version this CLI supports (the only command allowed on a mismatched runtime)
795
1063
  mx help | version
796
1064
 
797
1065
  Repos (pristine clones):
798
1066
  mx repo add <git-url> [--name <n>] clone a repo into the runtime
799
1067
  mx repo ls [--porcelain]
800
- mx repo -n <name> fetch git fetch (+ ff current branch)
1068
+ mx repo -n <name> path print the repo container path (cd "$(mx repo -n <name> path)")
1069
+ mx repo -n <name> fetch git fetch (+ ff the checked-out and base branches)
1070
+ mx repo fetch --all fetch every repo, one by one
801
1071
  mx repo -n <name> info [--porcelain]
802
1072
  mx repo health [--porcelain] pure-local health summary for every pristine clone
803
1073
  mx repo -n <name> health [--porcelain] detailed health for one pristine clone
804
1074
  mx repo -n <name> rm refuses if any work uses it
805
1075
 
806
1076
  Works (features):
807
- mx work new <name> [--description <t>] creates folder + empty work.json + sessions/
1077
+ mx work new <name> [--description <t>] [-o|--open] creates folder + empty work.json + sessions/; -o opens a fullscreen Terminal (cd'd in) + editor on the workspace (macOS)
808
1078
  mx work ls [--all|--archived] [--porcelain] default: active only; --all includes archived; --archived shows archived only
809
1079
  mx work -n <name> info [--porcelain]
810
1080
  mx work -n <name> path print the work folder path (cd "$(mx work -n <name> path)")
1081
+ mx work -n <name> open (or -o) open the work's fullscreen Terminal + editor layout (macOS)
811
1082
  mx work -n <name> describe <text>
812
- mx work -n <name> worktree add <repo> [--branch <b>] [--base <ref>]
1083
+ mx work -n <name> worktree add <repo> [--branch <b>] [--base <ref>] [--no-hydrate] runs the repo's hydrate.sh after add unless --no-hydrate
813
1084
  mx work -n <name> worktree ls [--porcelain]
814
1085
  mx work -n <name> worktree rm <repo> refuses on uncommitted changes; keeps branch
1086
+ mx work -n <name> worktree hydrate <repo> re-run the repo's hydrate.sh against its worktree
815
1087
  mx work -n <name> port set <repo> <service> [<port>] auto-picks a free port if omitted
816
1088
  mx work -n <name> port unset <repo> <service>
817
1089
  mx work -n <name> port ls [--porcelain]
@@ -827,20 +1099,89 @@ Runtime discovery: --runtime <path> -> $MX_RUNTIME -> default ~/mx.
827
1099
  `;
828
1100
 
829
1101
  // src/commands/global.ts
830
- import * as path8 from "path";
1102
+ import * as path7 from "path";
831
1103
 
832
1104
  // src/paths.ts
833
- import * as path7 from "path";
1105
+ import { readFileSync as readFileSync2 } from "fs";
1106
+ import * as path6 from "path";
834
1107
  import { fileURLToPath } from "url";
835
1108
  function templatesDir() {
836
1109
  if (process.env.MX_TEMPLATES_DIR) return process.env.MX_TEMPLATES_DIR;
837
- const here = path7.dirname(fileURLToPath(import.meta.url));
838
- return path7.join(here, "..", "templates");
1110
+ const here = path6.dirname(fileURLToPath(import.meta.url));
1111
+ return path6.join(here, "..", "templates");
1112
+ }
1113
+ function cliVersion() {
1114
+ const here = path6.dirname(fileURLToPath(import.meta.url));
1115
+ const pkg = JSON.parse(readFileSync2(path6.join(here, "..", "package.json"), "utf8"));
1116
+ return pkg.version;
1117
+ }
1118
+
1119
+ // src/selfupdate.ts
1120
+ import { spawnSync as spawnSync2 } from "child_process";
1121
+ var PKG = "@roulabs/mx";
1122
+ function major(v) {
1123
+ return Number.parseInt(v.split(".")[0], 10) || 0;
1124
+ }
1125
+ function npmAvailable() {
1126
+ try {
1127
+ const r = spawnSync2("npm", ["--version"], { stdio: ["ignore", "ignore", "ignore"] });
1128
+ return r.status === 0;
1129
+ } catch {
1130
+ return false;
1131
+ }
1132
+ }
1133
+ function npmViewJson(spec, field) {
1134
+ const r = spawnSync2("npm", ["view", spec, field, "--json"], {
1135
+ encoding: "utf8",
1136
+ stdio: ["ignore", "pipe", "ignore"]
1137
+ });
1138
+ if (r.status !== 0 || !r.stdout) return null;
1139
+ try {
1140
+ return JSON.parse(r.stdout);
1141
+ } catch {
1142
+ return null;
1143
+ }
1144
+ }
1145
+ function latestForRange(range) {
1146
+ const v = npmViewJson(`${PKG}@${range}`, "version");
1147
+ if (typeof v === "string") return v;
1148
+ if (Array.isArray(v) && v.length) return String(v[v.length - 1]);
1149
+ return null;
1150
+ }
1151
+ function latestOverall() {
1152
+ const v = npmViewJson(PKG, "version");
1153
+ return typeof v === "string" ? v : null;
1154
+ }
1155
+ function selfUpdate(porcelain2) {
1156
+ const current = cliVersion();
1157
+ const curMajor = major(current);
1158
+ const info = {
1159
+ package: PKG,
1160
+ current,
1161
+ npmAvailable: false,
1162
+ latestInMajor: null,
1163
+ updated: false,
1164
+ installFailed: false,
1165
+ newMajor: null
1166
+ };
1167
+ if (!npmAvailable()) return info;
1168
+ info.npmAvailable = true;
1169
+ info.latestInMajor = latestForRange(`^${curMajor}`);
1170
+ const overall = latestOverall();
1171
+ if (overall && major(overall) > curMajor) info.newMajor = major(overall);
1172
+ if (info.latestInMajor && info.latestInMajor !== current) {
1173
+ const r = spawnSync2("npm", ["i", "-g", `${PKG}@^${curMajor}`], {
1174
+ stdio: porcelain2 ? ["ignore", "pipe", "pipe"] : "inherit"
1175
+ });
1176
+ if (r.status === 0) info.updated = true;
1177
+ else info.installFailed = true;
1178
+ }
1179
+ return info;
839
1180
  }
840
1181
 
841
1182
  // src/commands/global.ts
842
1183
  function runtimeEnvHint(runtime) {
843
- const envRuntime = process.env.MX_RUNTIME ? path8.resolve(process.env.MX_RUNTIME) : null;
1184
+ const envRuntime = process.env.MX_RUNTIME ? path7.resolve(process.env.MX_RUNTIME) : null;
844
1185
  if (envRuntime === runtime) {
845
1186
  return ["", `${dim("$MX_RUNTIME already points here \u2014 you're set.")}`];
846
1187
  }
@@ -868,28 +1209,71 @@ function runGlobal(positionals, flags) {
868
1209
  }, res);
869
1210
  return;
870
1211
  }
871
- case "status": {
1212
+ case "info": {
872
1213
  const root = requireRuntime({ runtime: flags.runtime });
873
1214
  const data = statusRuntime(root, { includeArchived: flags.all });
874
1215
  emit(() => renderStatus(data), data);
875
1216
  return;
876
1217
  }
877
- case "update": {
1218
+ case "sync": {
878
1219
  const root = requireRuntime({ runtime: flags.runtime });
879
- const res = updateRuntime(root, templatesDir());
1220
+ const res = syncRuntime(root, templatesDir());
880
1221
  emit(() => {
881
- console.log(`${check()} Updated runtime at ${bold(res.runtime)}`);
1222
+ console.log(`${check()} Synced runtime at ${bold(res.runtime)}`);
882
1223
  for (const p of res.updated) console.log(` ${dim(`+ ${p}`)}`);
883
1224
  }, res);
884
1225
  return;
885
1226
  }
1227
+ case "migrate": {
1228
+ const root = requireRuntime({ runtime: flags.runtime, allowVersionMismatch: true });
1229
+ const res = migrateRuntime(root);
1230
+ emit(() => {
1231
+ if (res.applied.length === 0) {
1232
+ console.log(`${check()} Runtime already at v${res.to} \u2014 nothing to migrate.`);
1233
+ return;
1234
+ }
1235
+ const steps = res.applied.map((a) => `v${a.from}\u2192v${a.to}`).join(", ");
1236
+ console.log(
1237
+ `${check()} Migrated runtime ${bold(`v${res.from}\u2192v${res.to}`)} ${dim(`(${steps})`)}`
1238
+ );
1239
+ for (const p of res.changed) console.log(` ${dim(`~ ${p}`)}`);
1240
+ }, res);
1241
+ return;
1242
+ }
1243
+ case "update": {
1244
+ const info = selfUpdate(flags.porcelain);
1245
+ emit(() => renderSelfUpdate(info), info);
1246
+ return;
1247
+ }
886
1248
  default:
887
1249
  throw new MxError(`unknown command: ${positionals[0]}`, "BAD_ARGS");
888
1250
  }
889
1251
  }
1252
+ function renderSelfUpdate(info) {
1253
+ const curMajor = Number.parseInt(info.current.split(".")[0], 10) || 0;
1254
+ const manual = `npm i -g ${info.package}@^${curMajor}`;
1255
+ if (!info.npmAvailable) {
1256
+ console.log(`${warn()} npm not found \u2014 update manually: ${bold(manual)}`);
1257
+ } else if (info.installFailed) {
1258
+ console.log(`${warn()} self-update failed \u2014 try: ${bold(manual)}`);
1259
+ } else if (info.updated) {
1260
+ console.log(
1261
+ `${check()} Updated mx to ${bold(`v${info.latestInMajor}`)} ${dim(`(was v${info.current})`)}`
1262
+ );
1263
+ } else {
1264
+ console.log(`${check()} mx is up to date ${dim(`(v${info.current}, latest in v${curMajor}.x)`)}`);
1265
+ }
1266
+ if (info.newMajor) {
1267
+ console.log();
1268
+ console.log(`${warn()} A new major release ${bold(`mx v${info.newMajor}`)} is available.`);
1269
+ console.log(
1270
+ ` ${dim(`Upgrade (optional): npm i -g ${info.package}@${info.newMajor}, then `)}${bold("mx migrate")}`
1271
+ );
1272
+ }
1273
+ }
890
1274
  function renderStatus(data) {
891
1275
  console.log();
892
- console.log(` ${bold("mx")} ${dim("\xB7")} ${data.runtime}`);
1276
+ console.log(` ${bold("mx")} ${dim(`v${data.version}`)} ${dim("\xB7")} ${data.runtime}`);
893
1277
  console.log();
894
1278
  console.log(` ${bold("context")} ${dim(`(${data.context.entries})`)}`);
895
1279
  console.log();
@@ -897,21 +1281,19 @@ function renderStatus(data) {
897
1281
  if (data.repos.length === 0) {
898
1282
  console.log(` ${dim("none yet \u2014 `mx repo add <git-url>`")}`);
899
1283
  } else {
900
- const nameW = Math.max(...data.repos.map((r) => r.name.length));
901
- const branchW = Math.max(...data.repos.map((r) => r.branch.length));
902
- for (const r of data.repos) {
903
- const name = r.name.padEnd(nameW);
904
- const branch = dim(r.branch.padEnd(branchW));
905
- const remote = dim(r.remote ?? "(no remote)");
906
- console.log(` \u2022 ${name} ${branch} ${remote}`);
1284
+ for (let i = 0; i < data.repos.length; i++) {
1285
+ if (i > 0) console.log();
1286
+ const r = data.repos[i];
1287
+ console.log(` \u2022 ${bold(r.name)}`);
1288
+ console.log(` ${dim(tildify(r.path))}`);
1289
+ console.log(` ${dim(`${r.branch} ${r.remote ?? "(no remote)"}`)}`);
907
1290
  }
908
1291
  }
909
1292
  console.log();
910
1293
  const visibleArchived = data.works.filter((w) => w.isArchived === true);
911
1294
  const visibleActive = data.works.filter((w) => w.isArchived !== true);
912
1295
  const hiddenArchived = data.archivedWorksCount - visibleArchived.length;
913
- const worksCount = data.archivedWorksCount > 0 ? dim(`(${visibleActive.length} active, ${data.archivedWorksCount} archived${hiddenArchived > 0 ? ` \u2014 pass --all to show` : ""})`) : dim(`(${data.works.length})`);
914
- console.log(` ${bold("works")} ${worksCount}`);
1296
+ console.log(` ${bold("works")}`);
915
1297
  if (data.works.length === 0) {
916
1298
  const empty = hiddenArchived > 0 ? `${hiddenArchived} archived hidden \u2014 pass --all to show` : "none yet \u2014 `mx work new <name>`";
917
1299
  console.log(` ${dim(empty)}`);
@@ -930,6 +1312,7 @@ function renderStatus(data) {
930
1312
  const chip = w.isArchived === true ? ` ${dim(`[archived ${(w.archived_at ?? "").slice(0, 10)}]`)}` : "";
931
1313
  const styledName = w.isArchived === true ? dim(w.name) : bold(w.name);
932
1314
  console.log(` \u2022 ${styledName}${chip}`);
1315
+ console.log(` ${dim(tildify(w.path))}`);
933
1316
  if (wts.length === 0) {
934
1317
  console.log(` ${dim("(no worktrees)")}`);
935
1318
  continue;
@@ -958,6 +1341,7 @@ function dispatchRepo(positionals, flags) {
958
1341
  case "add": {
959
1342
  const url = need(positionals[2], "usage: mx repo add <git-url> [--name <n>]");
960
1343
  const res = repoAdd(root, url, flags.name);
1344
+ stampRepoScripts(repoPath(root, res.name), templatesDir());
961
1345
  emit(() => console.log(`${check()} cloned ${bold(res.name)} ${dim(`\u2192 ${res.path}`)}`), res);
962
1346
  return;
963
1347
  }
@@ -968,21 +1352,53 @@ function dispatchRepo(positionals, flags) {
968
1352
  console.log(dim("no repos yet \u2014 `mx repo add <git-url>`"));
969
1353
  return;
970
1354
  }
971
- const nameW = Math.max(...repos.map((r) => r.name.length));
972
- const branchW = Math.max(...repos.map((r) => r.branch.length));
973
- for (const r of repos) {
974
- const name = r.name.padEnd(nameW);
975
- const branch = dim(r.branch.padEnd(branchW));
976
- const remote = dim(r.remote ?? "(no remote)");
977
- console.log(`\u2022 ${name} ${branch} ${remote}`);
1355
+ for (let i = 0; i < repos.length; i++) {
1356
+ if (i > 0) console.log();
1357
+ const r = repos[i];
1358
+ console.log(`\u2022 ${bold(r.name)}`);
1359
+ console.log(` ${dim(tildify(r.path))}`);
1360
+ console.log(` ${dim(`${r.branch} ${r.remote ?? "(no remote)"}`)}`);
978
1361
  }
979
1362
  }, repos);
980
1363
  return;
981
1364
  }
1365
+ case "path": {
1366
+ const name = need(
1367
+ flags.name || ctxRepo,
1368
+ "which repo? pass -n <name> or run inside a repo (mx repo -n <name> path)"
1369
+ );
1370
+ const res = repoInfo(root, name);
1371
+ emit(() => console.log(res.path), { name: res.name, path: res.path });
1372
+ return;
1373
+ }
982
1374
  case "fetch": {
1375
+ if (flags.all) {
1376
+ const names = listReposInfo(root).map((r) => r.name);
1377
+ if (names.length === 0) {
1378
+ emit(() => console.log(dim("no repos yet \u2014 `mx repo add <git-url>`")), []);
1379
+ return;
1380
+ }
1381
+ const out = [];
1382
+ const lines = [];
1383
+ for (const n of names) {
1384
+ try {
1385
+ const r = repoFetch(root, n);
1386
+ out.push(r);
1387
+ lines.push(
1388
+ `${check()} ${bold(r.name)} ${dim(`\u2014 ${r.remoteBranches.length} branch(es) on origin, now on ${r.branch}`)}`
1389
+ );
1390
+ } catch (e) {
1391
+ const msg = e instanceof MxError ? e.message : String(e);
1392
+ out.push({ name: n, error: msg });
1393
+ lines.push(`${warn()} ${bold(n)} ${dim(`\u2014 ${msg}`)}`);
1394
+ }
1395
+ }
1396
+ emit(() => lines.forEach((l) => console.log(l)), out);
1397
+ return;
1398
+ }
983
1399
  const name = need(
984
1400
  flags.name || ctxRepo,
985
- "which repo? pass -n <name> or run inside a repo (mx repo -n <name> fetch)"
1401
+ "which repo? pass -n <name>, run inside a repo, or use --all (mx repo -n <name> fetch)"
986
1402
  );
987
1403
  const res = repoFetch(root, name);
988
1404
  emit(
@@ -1116,6 +1532,115 @@ function renderHealthDetail(h) {
1116
1532
  const hint = r.hint ? ` ${dim(r.hint)}` : "";
1117
1533
  console.log(` ${label} ${value}${marker}${hint}`);
1118
1534
  }
1535
+ if (h.extra) {
1536
+ console.log();
1537
+ console.log(` ${dim("health.sh")}`);
1538
+ for (const line of h.extra.split("\n")) console.log(` ${dim(line)}`);
1539
+ }
1540
+ }
1541
+
1542
+ // src/commands/work.ts
1543
+ import * as path8 from "path";
1544
+
1545
+ // src/open.ts
1546
+ import { execFileSync as execFileSync3 } from "child_process";
1547
+ function osascript(script) {
1548
+ try {
1549
+ execFileSync3("osascript", ["-e", script], { stdio: ["ignore", "ignore", "pipe"] });
1550
+ } catch (e) {
1551
+ const err = e;
1552
+ throw new MxError(
1553
+ `osascript failed: ${(err.stderr ?? err.message ?? "").toString().trim()}`,
1554
+ "OSASCRIPT"
1555
+ );
1556
+ }
1557
+ }
1558
+ function aplStr(s) {
1559
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1560
+ }
1561
+ function openWorkLayout(workdir, workspace) {
1562
+ if (process.platform !== "darwin") {
1563
+ throw new MxError("-o/--open is only supported on macOS", "UNSUPPORTED");
1564
+ }
1565
+ let editorProcess = "Cursor";
1566
+ try {
1567
+ execFileSync3("open", ["-a", "Cursor", workspace], { stdio: "ignore" });
1568
+ } catch {
1569
+ try {
1570
+ execFileSync3("open", ["-a", "Visual Studio Code", workspace], { stdio: "ignore" });
1571
+ editorProcess = "Code";
1572
+ } catch {
1573
+ editorProcess = "";
1574
+ }
1575
+ }
1576
+ osascript(
1577
+ [
1578
+ 'tell application "Terminal"',
1579
+ " activate",
1580
+ " set winCountBefore to count of windows",
1581
+ ` do script "cd \\"${aplStr(workdir)}\\""`,
1582
+ " delay 0.5",
1583
+ " set winCountAfter to count of windows",
1584
+ "end tell",
1585
+ "if winCountAfter is less than or equal to winCountBefore then",
1586
+ ' tell application "System Events" to tell process "Terminal"',
1587
+ " try",
1588
+ ' click menu item "Move Tab to New Window" of menu "Window" of menu bar 1',
1589
+ " delay 0.4",
1590
+ " end try",
1591
+ " end tell",
1592
+ "end if",
1593
+ 'tell application "System Events" to tell process "Terminal"',
1594
+ " try",
1595
+ ' set value of attribute "AXFullScreen" of window 1 to true',
1596
+ " end try",
1597
+ "end tell"
1598
+ ].join("\n")
1599
+ );
1600
+ if (editorProcess) {
1601
+ const appName = editorProcess === "Code" ? "Visual Studio Code" : "Cursor";
1602
+ osascript(
1603
+ [
1604
+ `tell application "${appName}" to activate`,
1605
+ // wait until the app is frontmost (cold launch can take a few seconds)
1606
+ 'tell application "System Events"',
1607
+ " set n to 0",
1608
+ ` repeat until (exists process "${editorProcess}") and (frontmost of process "${editorProcess}" is true)`,
1609
+ " delay 0.3",
1610
+ " set n to n + 1",
1611
+ " if n > 40 then exit repeat",
1612
+ " end repeat",
1613
+ "end tell",
1614
+ // let the workbench finish loading so it accepts the keybinding
1615
+ "delay 1.2",
1616
+ 'tell application "System Events" to key code 3 using {control down, command down}'
1617
+ ].join("\n")
1618
+ );
1619
+ }
1620
+ }
1621
+
1622
+ // src/hydrate.ts
1623
+ import { spawnSync as spawnSync3 } from "child_process";
1624
+ import { existsSync as existsSync2 } from "fs";
1625
+ function runWorktreeHydrate(ctx, quiet) {
1626
+ const script = repoHydrateScript(ctx.root, ctx.repo);
1627
+ if (!existsSync2(script)) return { ran: false, ok: true, missing: true };
1628
+ const env = {
1629
+ ...process.env,
1630
+ MX_RUNTIME: ctx.root,
1631
+ MX_WORK: ctx.work,
1632
+ MX_REPO: ctx.repo,
1633
+ MX_WORKTREE_PATH: ctx.worktreePath,
1634
+ MX_BRANCH: ctx.branch,
1635
+ MX_BASE: ctx.base ?? "",
1636
+ MX_WORK_PATH: workPath(ctx.root, ctx.work).path
1637
+ };
1638
+ const r = spawnSync3(script, [ctx.worktreePath, ctx.branch], {
1639
+ cwd: ctx.worktreePath,
1640
+ env,
1641
+ stdio: quiet ? ["ignore", "ignore", "ignore"] : "inherit"
1642
+ });
1643
+ return { ran: true, ok: r.status === 0, missing: false };
1119
1644
  }
1120
1645
 
1121
1646
  // src/commands/work.ts
@@ -1124,15 +1649,25 @@ function need2(v, msg) {
1124
1649
  return v;
1125
1650
  }
1126
1651
  function dispatchWork(positionals, flags) {
1127
- const action = positionals[1];
1652
+ let action = positionals[1];
1653
+ if (!action && flags.open) action = "open";
1128
1654
  if (action === "new") {
1129
1655
  const root2 = requireRuntime({ runtime: flags.runtime });
1130
- const name2 = need2(positionals[2], "usage: mx work new <name> [--description <text>]");
1656
+ const name2 = need2(positionals[2], "usage: mx work new <name> [--description <text>] [-o|--open]");
1131
1657
  const res = workNew(root2, name2, flags.description ?? "");
1132
1658
  emit(() => {
1133
1659
  console.log(`${check()} created work ${bold(res.name)}`);
1134
1660
  console.log(` ${dim(res.path)}`);
1135
1661
  }, res);
1662
+ if (flags.open) {
1663
+ try {
1664
+ openWorkLayout(res.path, workspaceFile(root2, res.name));
1665
+ } catch (e) {
1666
+ const msg = e instanceof MxError ? e.message : String(e);
1667
+ process.stderr.write(`${warn()} ${dim(`could not open layout: ${msg}`)}
1668
+ `);
1669
+ }
1670
+ }
1136
1671
  return;
1137
1672
  }
1138
1673
  if (action === "ls") {
@@ -1157,6 +1692,7 @@ function dispatchWork(positionals, flags) {
1157
1692
  const chip = w.isArchived === true ? ` ${dim(`[archived ${(w.archived_at ?? "").slice(0, 10)}]`)}` : "";
1158
1693
  const styledName = w.isArchived === true ? dim(w.name) : bold(w.name);
1159
1694
  console.log(`\u2022 ${styledName}${chip}`);
1695
+ console.log(` ${dim(tildify(w.path))}`);
1160
1696
  if (w.description) {
1161
1697
  console.log(` ${dim(`\u2014 ${w.description}`)}`);
1162
1698
  }
@@ -1204,6 +1740,22 @@ function dispatchWork(positionals, flags) {
1204
1740
  emit(() => console.log(res.path), res);
1205
1741
  return;
1206
1742
  }
1743
+ case "open": {
1744
+ const res = workPath(root, name);
1745
+ try {
1746
+ openWorkLayout(res.path, workspaceFile(root, name));
1747
+ } catch (e) {
1748
+ const msg = e instanceof MxError ? e.message : String(e);
1749
+ process.stderr.write(`${warn()} ${dim(`could not open layout: ${msg}`)}
1750
+ `);
1751
+ return;
1752
+ }
1753
+ emit(
1754
+ () => console.log(`${check()} opened ${bold(name)} ${dim("(Terminal + editor)")}`),
1755
+ { work: name, opened: true }
1756
+ );
1757
+ return;
1758
+ }
1207
1759
  case "describe": {
1208
1760
  const text = need2(positionals[2], "usage: mx work -n <name> describe <text>");
1209
1761
  const work = workDescribe(root, name, text);
@@ -1294,7 +1846,7 @@ function workWorktree(root, name, positionals, flags) {
1294
1846
  case "add": {
1295
1847
  const repo = need2(
1296
1848
  positionals[3],
1297
- "usage: mx work -n <name> worktree add <repo> [--branch <b>] [--base <ref>]"
1849
+ "usage: mx work -n <name> worktree add <repo> [--branch <b>] [--base <ref>] [--no-hydrate]"
1298
1850
  );
1299
1851
  const res = worktreeAdd(root, name, repo, { branch: flags.branch, base: flags.base });
1300
1852
  emit(
@@ -1303,6 +1855,18 @@ function workWorktree(root, name, positionals, flags) {
1303
1855
  ),
1304
1856
  res
1305
1857
  );
1858
+ if (!flags.noHydrate) {
1859
+ const outcome = runWorktreeHydrate(
1860
+ { root, work: name, repo: res.repo, worktreePath: res.path, branch: res.branch, base: flags.base },
1861
+ flags.porcelain
1862
+ );
1863
+ if (outcome.ran && !outcome.ok && !flags.porcelain) {
1864
+ process.stderr.write(
1865
+ `${warn()} ${dim(`hydrate.sh for ${res.repo} exited non-zero \u2014 worktree kept. Re-run: mx work -n ${name} worktree hydrate ${res.repo}`)}
1866
+ `
1867
+ );
1868
+ }
1869
+ }
1306
1870
  return;
1307
1871
  }
1308
1872
  case "ls": {
@@ -1334,6 +1898,31 @@ function workWorktree(root, name, positionals, flags) {
1334
1898
  );
1335
1899
  return;
1336
1900
  }
1901
+ case "hydrate": {
1902
+ const repo = need2(positionals[3], "usage: mx work -n <name> worktree hydrate <repo>");
1903
+ const wt = worktreeList(root, name).find((w) => w.repo === repo);
1904
+ if (!wt) throw new MxError(`work "${name}" has no worktree for ${repo}`, "NO_WORKTREE");
1905
+ const worktreePath2 = path8.join(workPath(root, name).path, "wt", repo);
1906
+ const outcome = runWorktreeHydrate(
1907
+ { root, work: name, repo, worktreePath: worktreePath2, branch: wt.branch },
1908
+ flags.porcelain
1909
+ );
1910
+ if (outcome.missing) {
1911
+ emit(
1912
+ () => console.log(dim(`no hydrate.sh for ${repo} \u2014 nothing to run`)),
1913
+ { work: name, repo, ran: false }
1914
+ );
1915
+ return;
1916
+ }
1917
+ if (!outcome.ok) throw new MxError(`hydrate.sh for ${repo} exited non-zero`, "HYDRATE_FAILED");
1918
+ emit(() => console.log(`${check()} hydrated ${bold(repo)}`), {
1919
+ work: name,
1920
+ repo,
1921
+ ran: true,
1922
+ ok: true
1923
+ });
1924
+ return;
1925
+ }
1337
1926
  default:
1338
1927
  throw new MxError(`unknown worktree command: ${sub ?? "(none)"}`, "BAD_ARGS");
1339
1928
  }
@@ -1404,13 +1993,13 @@ function workPort(root, name, positionals) {
1404
1993
  // src/main.ts
1405
1994
  var VERSION = (() => {
1406
1995
  const here = path9.dirname(fileURLToPath2(import.meta.url));
1407
- const pkg = JSON.parse(readFileSync2(path9.join(here, "..", "package.json"), "utf8"));
1996
+ const pkg = JSON.parse(readFileSync3(path9.join(here, "..", "package.json"), "utf8"));
1408
1997
  return pkg.version;
1409
1998
  })();
1410
1999
  function main() {
1411
2000
  const { positionals, flags } = parseArgs(process.argv.slice(2));
1412
2001
  setPorcelain(flags.porcelain);
1413
- if (positionals[0] === "s" || positionals[0] === "st") positionals[0] = "status";
2002
+ if (positionals[0] === "i") positionals[0] = "info";
1414
2003
  try {
1415
2004
  if (flags.version || positionals[0] === "version") {
1416
2005
  emit(() => console.log(`mx ${VERSION}`), { version: VERSION });
@@ -1422,8 +2011,10 @@ function main() {
1422
2011
  }
1423
2012
  switch (positionals[0]) {
1424
2013
  case "init":
1425
- case "status":
2014
+ case "info":
2015
+ case "sync":
1426
2016
  case "update":
2017
+ case "migrate":
1427
2018
  return runGlobal(positionals, flags);
1428
2019
  case "repo":
1429
2020
  return dispatchRepo(positionals, flags);