@roulabs/mx 1.10.1 → 2.0.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,98 @@ 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"]);
273
494
  gitQuiet(["-C", rp, "merge", "--ff-only", "@{u}"]);
274
495
  return { name, branch: currentBranch(rp), remoteBranches: remoteBranchList(rp) };
275
496
  }
276
497
  function repoInfo(root, name) {
277
- const rp = repoPath(root, name);
498
+ const rp = repoGitDir(root, name);
278
499
  if (!isGitRepo(rp)) throw new MxError(`no such repo: ${name}`, "NO_REPO");
279
500
  const usedBy = listWorkNames(root).filter((w) => findWorktree(readWork(root, w), name));
280
501
  return {
281
502
  name,
282
- path: rp,
503
+ path: repoPath(root, name),
504
+ // the container (repo home)
283
505
  branch: currentBranch(rp),
284
506
  remote: remoteUrl(rp),
285
507
  worktreesInWorks: usedBy
@@ -321,8 +543,30 @@ function lastFetched(rp) {
321
543
  if (!exists(fh)) return null;
322
544
  return fs5.statSync(fh).mtime.toISOString();
323
545
  }
546
+ function runHealthScript(root, name) {
547
+ const script = repoHealthScript(root, name);
548
+ if (!exists(script)) return null;
549
+ try {
550
+ const out = execFileSync2(script, [], {
551
+ cwd: repoGitDir(root, name),
552
+ encoding: "utf8",
553
+ stdio: ["ignore", "pipe", "ignore"],
554
+ env: {
555
+ ...process.env,
556
+ MX_RUNTIME: root,
557
+ MX_REPO: name,
558
+ MX_REPO_PATH: repoPath(root, name),
559
+ MX_GIT_DIR: repoGitDir(root, name)
560
+ }
561
+ });
562
+ const trimmed = out.trim();
563
+ return trimmed.length ? trimmed : null;
564
+ } catch {
565
+ return null;
566
+ }
567
+ }
324
568
  function repoHealth(root, name) {
325
- const rp = repoPath(root, name);
569
+ const rp = repoGitDir(root, name);
326
570
  if (!isGitRepo(rp)) throw new MxError(`no such repo: ${name}`, "NO_REPO");
327
571
  const defaultBranch = originDefaultBranch(rp);
328
572
  const current = currentBranchOrNull(rp);
@@ -355,7 +599,8 @@ function repoHealth(root, name) {
355
599
  }
356
600
  return {
357
601
  name,
358
- path: rp,
602
+ path: repoPath(root, name),
603
+ // the container (repo home)
359
604
  defaultBranch,
360
605
  currentBranch: current,
361
606
  isOnDefault,
@@ -366,15 +611,15 @@ function repoHealth(root, name) {
366
611
  lastFetchedAt,
367
612
  worktreesInWorks,
368
613
  healthy: issues.length === 0,
369
- issues
614
+ issues,
615
+ extra: runHealthScript(root, name)
370
616
  };
371
617
  }
372
618
  function listRepoHealth(root) {
373
619
  return listRepoNames(root).map((name) => repoHealth(root, name));
374
620
  }
375
621
  function repoRemove(root, name) {
376
- const rp = repoPath(root, name);
377
- if (!isGitRepo(rp)) throw new MxError(`no such repo: ${name}`, "NO_REPO");
622
+ if (!isGitRepo(repoGitDir(root, name))) throw new MxError(`no such repo: ${name}`, "NO_REPO");
378
623
  const usedBy = listWorkNames(root).filter((w) => findWorktree(readWork(root, w), name));
379
624
  if (usedBy.length) {
380
625
  throw new MxError(
@@ -382,25 +627,25 @@ function repoRemove(root, name) {
382
627
  "IN_USE"
383
628
  );
384
629
  }
385
- fs5.rmSync(rp, { recursive: true, force: true });
630
+ fs5.rmSync(repoPath(root, name), { recursive: true, force: true });
386
631
  return { name, removed: true };
387
632
  }
388
633
 
389
634
  // ../../packages/core/src/works.ts
390
635
  import * as fs6 from "fs";
391
- import * as path5 from "path";
392
636
  function addFolderToWorkspace(root, name, repo) {
393
637
  const file = workspaceFile(root, name);
394
638
  const ws = exists(file) ? readJson(file) : { folders: [], settings: {} };
395
639
  ws.folders = ws.folders ?? [];
396
- if (!ws.folders.some((f) => f.path === repo)) ws.folders.push({ name: repo, path: repo });
640
+ const rel = `wt/${repo}`;
641
+ if (!ws.folders.some((f) => f.path === rel)) ws.folders.push({ name: repo, path: rel });
397
642
  writeJson(file, ws);
398
643
  }
399
644
  function removeFolderFromWorkspace(root, name, repo) {
400
645
  const file = workspaceFile(root, name);
401
646
  if (!exists(file)) return;
402
647
  const ws = readJson(file);
403
- ws.folders = (ws.folders ?? []).filter((f) => f.path !== repo);
648
+ ws.folders = (ws.folders ?? []).filter((f) => f.path !== `wt/${repo}`);
404
649
  writeJson(file, ws);
405
650
  }
406
651
  function clearWorkspaceFolders(root, name) {
@@ -423,6 +668,7 @@ function workNew(root, name, description = "") {
423
668
  function listWorksInfo(root, opts = {}) {
424
669
  return listWorkNames(root).map((name) => ({
425
670
  ...readWork(root, name),
671
+ path: workDir(root, name),
426
672
  sessions: countSessions(root, name)
427
673
  })).filter((w) => {
428
674
  if (opts.onlyArchived) return w.isArchived === true;
@@ -446,13 +692,14 @@ function workPath(root, name) {
446
692
  }
447
693
  function worktreeAdd(root, name, repo, opts = {}) {
448
694
  const work = readWork(root, name);
449
- const rp = repoPath(root, repo);
695
+ const rp = repoGitDir(root, repo);
450
696
  if (!isGitRepo(rp)) throw new MxError(`no such repo: ${repo}`, "NO_REPO");
451
697
  if (findWorktree(work, repo)) {
452
698
  throw new MxError(`work "${name}" already has worktree for ${repo}`, "EXISTS");
453
699
  }
454
700
  const branch = opts.branch || name;
455
- const dest = path5.join(workDir(root, name), repo);
701
+ const dest = worktreePath(root, name, repo);
702
+ fs6.mkdirSync(worktreesDir(root, name), { recursive: true });
456
703
  if (branchExists(rp, branch)) {
457
704
  git(["-C", rp, "worktree", "add", dest, branch]);
458
705
  } else {
@@ -479,11 +726,11 @@ function worktreeRemove(root, name, repo) {
479
726
  const work = readWork(root, name);
480
727
  const wt = findWorktree(work, repo);
481
728
  if (!wt) throw new MxError(`work "${name}" has no worktree for ${repo}`, "NO_WORKTREE");
482
- const dest = path5.join(workDir(root, name), repo);
729
+ const dest = worktreePath(root, name, repo);
483
730
  if (exists(dest) && isDirty(dest)) {
484
731
  throw new MxError(`worktree ${repo} has uncommitted changes \u2014 commit or discard them first`, "DIRTY");
485
732
  }
486
- git(["-C", repoPath(root, repo), "worktree", "remove", dest]);
733
+ git(["-C", repoGitDir(root, repo), "worktree", "remove", dest]);
487
734
  work.worktrees = work.worktrees.filter((w) => w.repo !== repo);
488
735
  writeWork(root, work);
489
736
  removeFolderFromWorkspace(root, name, repo);
@@ -499,7 +746,7 @@ function workDestroy(root, name, opts = {}) {
499
746
  const work = readWork(root, name);
500
747
  const dirty = [];
501
748
  for (const wt of work.worktrees ?? []) {
502
- const dest = path5.join(workDir(root, name), wt.repo);
749
+ const dest = worktreePath(root, name, wt.repo);
503
750
  if (exists(dest) && isDirty(dest)) dirty.push(wt.repo);
504
751
  }
505
752
  if (dirty.length) {
@@ -510,8 +757,8 @@ function workDestroy(root, name, opts = {}) {
510
757
  }
511
758
  const removed = [];
512
759
  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]);
760
+ const dest = worktreePath(root, name, wt.repo);
761
+ if (exists(dest)) git(["-C", repoGitDir(root, wt.repo), "worktree", "remove", dest]);
515
762
  removed.push(wt.repo);
516
763
  }
517
764
  fs6.rmSync(workDir(root, name), { recursive: true, force: true });
@@ -524,7 +771,7 @@ function archiveWork(root, name) {
524
771
  }
525
772
  const dirty = [];
526
773
  for (const wt of work.worktrees ?? []) {
527
- const dest = path5.join(workDir(root, name), wt.repo);
774
+ const dest = worktreePath(root, name, wt.repo);
528
775
  if (exists(dest) && isDirty(dest)) dirty.push(wt.repo);
529
776
  }
530
777
  if (dirty.length) {
@@ -535,8 +782,8 @@ function archiveWork(root, name) {
535
782
  }
536
783
  const removed = [];
537
784
  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]);
785
+ const dest = worktreePath(root, name, wt.repo);
786
+ if (exists(dest)) git(["-C", repoGitDir(root, wt.repo), "worktree", "remove", dest]);
540
787
  removed.push(wt.repo);
541
788
  }
542
789
  clearWorkspaceFolders(root, name);
@@ -558,7 +805,7 @@ function unarchiveWork(root, name, overrides = {}) {
558
805
  }));
559
806
  const missing = [];
560
807
  for (const d of desired) {
561
- const rp = repoPath(root, d.repo);
808
+ const rp = repoGitDir(root, d.repo);
562
809
  if (!isGitRepo(rp)) {
563
810
  throw new MxError(`pristine clone missing for repo: ${d.repo}`, "NO_REPO");
564
811
  }
@@ -574,8 +821,9 @@ function unarchiveWork(root, name, overrides = {}) {
574
821
  }
575
822
  const restored = [];
576
823
  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]);
824
+ const dest = worktreePath(root, name, d.repo);
825
+ fs6.mkdirSync(worktreesDir(root, name), { recursive: true });
826
+ git(["-C", repoGitDir(root, d.repo), "worktree", "add", dest, d.branch]);
579
827
  addFolderToWorkspace(root, name, d.repo);
580
828
  restored.push({ repo: d.repo, branch: d.branch, path: dest, ports: d.ports });
581
829
  }
@@ -654,9 +902,9 @@ function portList(root, name) {
654
902
  }
655
903
 
656
904
  // ../../packages/core/src/status.ts
657
- import * as path6 from "path";
905
+ import * as path5 from "path";
658
906
  function countContextEntries(root) {
659
- const indexPath = path6.join(root, "context", "INDEX.json");
907
+ const indexPath = path5.join(root, "context", "INDEX.json");
660
908
  if (!exists(indexPath)) return 0;
661
909
  try {
662
910
  const data = readJson(indexPath);
@@ -696,7 +944,9 @@ function parseArgs(argv) {
696
944
  force: false,
697
945
  yes: false,
698
946
  all: false,
699
- archived: false
947
+ archived: false,
948
+ open: false,
949
+ noHydrate: false
700
950
  };
701
951
  for (let i = 0; i < argv.length; i++) {
702
952
  const a = argv[i];
@@ -714,6 +964,10 @@ function parseArgs(argv) {
714
964
  flags.all = true;
715
965
  } else if (a === "--archived") {
716
966
  flags.archived = true;
967
+ } else if (a === "--open" || a === "-o") {
968
+ flags.open = true;
969
+ } else if (a === "--no-hydrate") {
970
+ flags.noHydrate = true;
717
971
  } else if (a.startsWith("--") && a.includes("=")) {
718
972
  const eq = a.indexOf("=");
719
973
  const key = VALUE_FLAGS[a.slice(0, eq)];
@@ -730,6 +984,7 @@ function parseArgs(argv) {
730
984
 
731
985
  // src/output.ts
732
986
  import { spawnSync } from "child_process";
987
+ import * as os2 from "os";
733
988
  var USE_STYLE = process.stdout.isTTY && !process.env.NO_COLOR;
734
989
  function wrap(s, code) {
735
990
  return USE_STYLE ? `\x1B[${code}m${s}\x1B[0m` : s;
@@ -746,6 +1001,11 @@ function check() {
746
1001
  function warn() {
747
1002
  return "\u26A0";
748
1003
  }
1004
+ function tildify(p) {
1005
+ const home = os2.homedir();
1006
+ if (p === home) return "~";
1007
+ return p.startsWith(home + "/") ? "~" + p.slice(home.length) : p;
1008
+ }
749
1009
  function confirmYesNo(prompt) {
750
1010
  if (!process.stdin.isTTY) return false;
751
1011
  process.stdout.write(prompt);
@@ -791,7 +1051,9 @@ var HELP = `mx \u2014 control panel for the mx runtime
791
1051
  Global:
792
1052
  mx init [path] scaffold/adopt a runtime (default ~/mx)
793
1053
  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
1054
+ mx sync re-stamp runtime files (CLAUDE.md, scaffolding) from current templates \u2014 same-major, non-breaking
1055
+ mx update self-update the mx CLI within its major (npm i -g); flags a newer major if one exists
1056
+ mx migrate upgrade an older-version runtime to the version this CLI supports (the only command allowed on a mismatched runtime)
795
1057
  mx help | version
796
1058
 
797
1059
  Repos (pristine clones):
@@ -804,14 +1066,15 @@ Repos (pristine clones):
804
1066
  mx repo -n <name> rm refuses if any work uses it
805
1067
 
806
1068
  Works (features):
807
- mx work new <name> [--description <t>] creates folder + empty work.json + sessions/
1069
+ 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
1070
  mx work ls [--all|--archived] [--porcelain] default: active only; --all includes archived; --archived shows archived only
809
1071
  mx work -n <name> info [--porcelain]
810
1072
  mx work -n <name> path print the work folder path (cd "$(mx work -n <name> path)")
811
1073
  mx work -n <name> describe <text>
812
- mx work -n <name> worktree add <repo> [--branch <b>] [--base <ref>]
1074
+ 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
1075
  mx work -n <name> worktree ls [--porcelain]
814
1076
  mx work -n <name> worktree rm <repo> refuses on uncommitted changes; keeps branch
1077
+ mx work -n <name> worktree hydrate <repo> re-run the repo's hydrate.sh against its worktree
815
1078
  mx work -n <name> port set <repo> <service> [<port>] auto-picks a free port if omitted
816
1079
  mx work -n <name> port unset <repo> <service>
817
1080
  mx work -n <name> port ls [--porcelain]
@@ -827,20 +1090,89 @@ Runtime discovery: --runtime <path> -> $MX_RUNTIME -> default ~/mx.
827
1090
  `;
828
1091
 
829
1092
  // src/commands/global.ts
830
- import * as path8 from "path";
1093
+ import * as path7 from "path";
831
1094
 
832
1095
  // src/paths.ts
833
- import * as path7 from "path";
1096
+ import { readFileSync as readFileSync2 } from "fs";
1097
+ import * as path6 from "path";
834
1098
  import { fileURLToPath } from "url";
835
1099
  function templatesDir() {
836
1100
  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");
1101
+ const here = path6.dirname(fileURLToPath(import.meta.url));
1102
+ return path6.join(here, "..", "templates");
1103
+ }
1104
+ function cliVersion() {
1105
+ const here = path6.dirname(fileURLToPath(import.meta.url));
1106
+ const pkg = JSON.parse(readFileSync2(path6.join(here, "..", "package.json"), "utf8"));
1107
+ return pkg.version;
1108
+ }
1109
+
1110
+ // src/selfupdate.ts
1111
+ import { spawnSync as spawnSync2 } from "child_process";
1112
+ var PKG = "@roulabs/mx";
1113
+ function major(v) {
1114
+ return Number.parseInt(v.split(".")[0], 10) || 0;
1115
+ }
1116
+ function npmAvailable() {
1117
+ try {
1118
+ const r = spawnSync2("npm", ["--version"], { stdio: ["ignore", "ignore", "ignore"] });
1119
+ return r.status === 0;
1120
+ } catch {
1121
+ return false;
1122
+ }
1123
+ }
1124
+ function npmViewJson(spec, field) {
1125
+ const r = spawnSync2("npm", ["view", spec, field, "--json"], {
1126
+ encoding: "utf8",
1127
+ stdio: ["ignore", "pipe", "ignore"]
1128
+ });
1129
+ if (r.status !== 0 || !r.stdout) return null;
1130
+ try {
1131
+ return JSON.parse(r.stdout);
1132
+ } catch {
1133
+ return null;
1134
+ }
1135
+ }
1136
+ function latestForRange(range) {
1137
+ const v = npmViewJson(`${PKG}@${range}`, "version");
1138
+ if (typeof v === "string") return v;
1139
+ if (Array.isArray(v) && v.length) return String(v[v.length - 1]);
1140
+ return null;
1141
+ }
1142
+ function latestOverall() {
1143
+ const v = npmViewJson(PKG, "version");
1144
+ return typeof v === "string" ? v : null;
1145
+ }
1146
+ function selfUpdate(porcelain2) {
1147
+ const current = cliVersion();
1148
+ const curMajor = major(current);
1149
+ const info = {
1150
+ package: PKG,
1151
+ current,
1152
+ npmAvailable: false,
1153
+ latestInMajor: null,
1154
+ updated: false,
1155
+ installFailed: false,
1156
+ newMajor: null
1157
+ };
1158
+ if (!npmAvailable()) return info;
1159
+ info.npmAvailable = true;
1160
+ info.latestInMajor = latestForRange(`^${curMajor}`);
1161
+ const overall = latestOverall();
1162
+ if (overall && major(overall) > curMajor) info.newMajor = major(overall);
1163
+ if (info.latestInMajor && info.latestInMajor !== current) {
1164
+ const r = spawnSync2("npm", ["i", "-g", `${PKG}@^${curMajor}`], {
1165
+ stdio: porcelain2 ? ["ignore", "pipe", "pipe"] : "inherit"
1166
+ });
1167
+ if (r.status === 0) info.updated = true;
1168
+ else info.installFailed = true;
1169
+ }
1170
+ return info;
839
1171
  }
840
1172
 
841
1173
  // src/commands/global.ts
842
1174
  function runtimeEnvHint(runtime) {
843
- const envRuntime = process.env.MX_RUNTIME ? path8.resolve(process.env.MX_RUNTIME) : null;
1175
+ const envRuntime = process.env.MX_RUNTIME ? path7.resolve(process.env.MX_RUNTIME) : null;
844
1176
  if (envRuntime === runtime) {
845
1177
  return ["", `${dim("$MX_RUNTIME already points here \u2014 you're set.")}`];
846
1178
  }
@@ -874,19 +1206,62 @@ function runGlobal(positionals, flags) {
874
1206
  emit(() => renderStatus(data), data);
875
1207
  return;
876
1208
  }
877
- case "update": {
1209
+ case "sync": {
878
1210
  const root = requireRuntime({ runtime: flags.runtime });
879
- const res = updateRuntime(root, templatesDir());
1211
+ const res = syncRuntime(root, templatesDir());
880
1212
  emit(() => {
881
- console.log(`${check()} Updated runtime at ${bold(res.runtime)}`);
1213
+ console.log(`${check()} Synced runtime at ${bold(res.runtime)}`);
882
1214
  for (const p of res.updated) console.log(` ${dim(`+ ${p}`)}`);
883
1215
  }, res);
884
1216
  return;
885
1217
  }
1218
+ case "migrate": {
1219
+ const root = requireRuntime({ runtime: flags.runtime, allowVersionMismatch: true });
1220
+ const res = migrateRuntime(root);
1221
+ emit(() => {
1222
+ if (res.applied.length === 0) {
1223
+ console.log(`${check()} Runtime already at v${res.to} \u2014 nothing to migrate.`);
1224
+ return;
1225
+ }
1226
+ const steps = res.applied.map((a) => `v${a.from}\u2192v${a.to}`).join(", ");
1227
+ console.log(
1228
+ `${check()} Migrated runtime ${bold(`v${res.from}\u2192v${res.to}`)} ${dim(`(${steps})`)}`
1229
+ );
1230
+ for (const p of res.changed) console.log(` ${dim(`~ ${p}`)}`);
1231
+ }, res);
1232
+ return;
1233
+ }
1234
+ case "update": {
1235
+ const info = selfUpdate(flags.porcelain);
1236
+ emit(() => renderSelfUpdate(info), info);
1237
+ return;
1238
+ }
886
1239
  default:
887
1240
  throw new MxError(`unknown command: ${positionals[0]}`, "BAD_ARGS");
888
1241
  }
889
1242
  }
1243
+ function renderSelfUpdate(info) {
1244
+ const curMajor = Number.parseInt(info.current.split(".")[0], 10) || 0;
1245
+ const manual = `npm i -g ${info.package}@^${curMajor}`;
1246
+ if (!info.npmAvailable) {
1247
+ console.log(`${warn()} npm not found \u2014 update manually: ${bold(manual)}`);
1248
+ } else if (info.installFailed) {
1249
+ console.log(`${warn()} self-update failed \u2014 try: ${bold(manual)}`);
1250
+ } else if (info.updated) {
1251
+ console.log(
1252
+ `${check()} Updated mx to ${bold(`v${info.latestInMajor}`)} ${dim(`(was v${info.current})`)}`
1253
+ );
1254
+ } else {
1255
+ console.log(`${check()} mx is up to date ${dim(`(v${info.current}, latest in v${curMajor}.x)`)}`);
1256
+ }
1257
+ if (info.newMajor) {
1258
+ console.log();
1259
+ console.log(`${warn()} A new major release ${bold(`mx v${info.newMajor}`)} is available.`);
1260
+ console.log(
1261
+ ` ${dim(`Upgrade (optional): npm i -g ${info.package}@${info.newMajor}, then `)}${bold("mx migrate")}`
1262
+ );
1263
+ }
1264
+ }
890
1265
  function renderStatus(data) {
891
1266
  console.log();
892
1267
  console.log(` ${bold("mx")} ${dim("\xB7")} ${data.runtime}`);
@@ -958,6 +1333,7 @@ function dispatchRepo(positionals, flags) {
958
1333
  case "add": {
959
1334
  const url = need(positionals[2], "usage: mx repo add <git-url> [--name <n>]");
960
1335
  const res = repoAdd(root, url, flags.name);
1336
+ stampRepoScripts(repoPath(root, res.name), templatesDir());
961
1337
  emit(() => console.log(`${check()} cloned ${bold(res.name)} ${dim(`\u2192 ${res.path}`)}`), res);
962
1338
  return;
963
1339
  }
@@ -975,6 +1351,7 @@ function dispatchRepo(positionals, flags) {
975
1351
  const branch = dim(r.branch.padEnd(branchW));
976
1352
  const remote = dim(r.remote ?? "(no remote)");
977
1353
  console.log(`\u2022 ${name} ${branch} ${remote}`);
1354
+ console.log(` ${dim(tildify(r.path))}`);
978
1355
  }
979
1356
  }, repos);
980
1357
  return;
@@ -1116,6 +1493,115 @@ function renderHealthDetail(h) {
1116
1493
  const hint = r.hint ? ` ${dim(r.hint)}` : "";
1117
1494
  console.log(` ${label} ${value}${marker}${hint}`);
1118
1495
  }
1496
+ if (h.extra) {
1497
+ console.log();
1498
+ console.log(` ${dim("health.sh")}`);
1499
+ for (const line of h.extra.split("\n")) console.log(` ${dim(line)}`);
1500
+ }
1501
+ }
1502
+
1503
+ // src/commands/work.ts
1504
+ import * as path8 from "path";
1505
+
1506
+ // src/open.ts
1507
+ import { execFileSync as execFileSync3 } from "child_process";
1508
+ function osascript(script) {
1509
+ try {
1510
+ execFileSync3("osascript", ["-e", script], { stdio: ["ignore", "ignore", "pipe"] });
1511
+ } catch (e) {
1512
+ const err = e;
1513
+ throw new MxError(
1514
+ `osascript failed: ${(err.stderr ?? err.message ?? "").toString().trim()}`,
1515
+ "OSASCRIPT"
1516
+ );
1517
+ }
1518
+ }
1519
+ function aplStr(s) {
1520
+ return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1521
+ }
1522
+ function openWorkLayout(workdir, workspace) {
1523
+ if (process.platform !== "darwin") {
1524
+ throw new MxError("-o/--open is only supported on macOS", "UNSUPPORTED");
1525
+ }
1526
+ let editorProcess = "Cursor";
1527
+ try {
1528
+ execFileSync3("open", ["-a", "Cursor", workspace], { stdio: "ignore" });
1529
+ } catch {
1530
+ try {
1531
+ execFileSync3("open", ["-a", "Visual Studio Code", workspace], { stdio: "ignore" });
1532
+ editorProcess = "Code";
1533
+ } catch {
1534
+ editorProcess = "";
1535
+ }
1536
+ }
1537
+ osascript(
1538
+ [
1539
+ 'tell application "Terminal"',
1540
+ " activate",
1541
+ " set winCountBefore to count of windows",
1542
+ ` do script "cd \\"${aplStr(workdir)}\\""`,
1543
+ " delay 0.5",
1544
+ " set winCountAfter to count of windows",
1545
+ "end tell",
1546
+ "if winCountAfter is less than or equal to winCountBefore then",
1547
+ ' tell application "System Events" to tell process "Terminal"',
1548
+ " try",
1549
+ ' click menu item "Move Tab to New Window" of menu "Window" of menu bar 1',
1550
+ " delay 0.4",
1551
+ " end try",
1552
+ " end tell",
1553
+ "end if",
1554
+ 'tell application "System Events" to tell process "Terminal"',
1555
+ " try",
1556
+ ' set value of attribute "AXFullScreen" of window 1 to true',
1557
+ " end try",
1558
+ "end tell"
1559
+ ].join("\n")
1560
+ );
1561
+ if (editorProcess) {
1562
+ const appName = editorProcess === "Code" ? "Visual Studio Code" : "Cursor";
1563
+ osascript(
1564
+ [
1565
+ `tell application "${appName}" to activate`,
1566
+ // wait until the app is frontmost (cold launch can take a few seconds)
1567
+ 'tell application "System Events"',
1568
+ " set n to 0",
1569
+ ` repeat until (exists process "${editorProcess}") and (frontmost of process "${editorProcess}" is true)`,
1570
+ " delay 0.3",
1571
+ " set n to n + 1",
1572
+ " if n > 40 then exit repeat",
1573
+ " end repeat",
1574
+ "end tell",
1575
+ // let the workbench finish loading so it accepts the keybinding
1576
+ "delay 1.2",
1577
+ 'tell application "System Events" to key code 3 using {control down, command down}'
1578
+ ].join("\n")
1579
+ );
1580
+ }
1581
+ }
1582
+
1583
+ // src/hydrate.ts
1584
+ import { spawnSync as spawnSync3 } from "child_process";
1585
+ import { existsSync as existsSync2 } from "fs";
1586
+ function runWorktreeHydrate(ctx, quiet) {
1587
+ const script = repoHydrateScript(ctx.root, ctx.repo);
1588
+ if (!existsSync2(script)) return { ran: false, ok: true, missing: true };
1589
+ const env = {
1590
+ ...process.env,
1591
+ MX_RUNTIME: ctx.root,
1592
+ MX_WORK: ctx.work,
1593
+ MX_REPO: ctx.repo,
1594
+ MX_WORKTREE_PATH: ctx.worktreePath,
1595
+ MX_BRANCH: ctx.branch,
1596
+ MX_BASE: ctx.base ?? "",
1597
+ MX_WORK_PATH: workPath(ctx.root, ctx.work).path
1598
+ };
1599
+ const r = spawnSync3(script, [ctx.worktreePath, ctx.branch], {
1600
+ cwd: ctx.worktreePath,
1601
+ env,
1602
+ stdio: quiet ? ["ignore", "ignore", "ignore"] : "inherit"
1603
+ });
1604
+ return { ran: true, ok: r.status === 0, missing: false };
1119
1605
  }
1120
1606
 
1121
1607
  // src/commands/work.ts
@@ -1127,12 +1613,21 @@ function dispatchWork(positionals, flags) {
1127
1613
  const action = positionals[1];
1128
1614
  if (action === "new") {
1129
1615
  const root2 = requireRuntime({ runtime: flags.runtime });
1130
- const name2 = need2(positionals[2], "usage: mx work new <name> [--description <text>]");
1616
+ const name2 = need2(positionals[2], "usage: mx work new <name> [--description <text>] [-o|--open]");
1131
1617
  const res = workNew(root2, name2, flags.description ?? "");
1132
1618
  emit(() => {
1133
1619
  console.log(`${check()} created work ${bold(res.name)}`);
1134
1620
  console.log(` ${dim(res.path)}`);
1135
1621
  }, res);
1622
+ if (flags.open) {
1623
+ try {
1624
+ openWorkLayout(res.path, workspaceFile(root2, res.name));
1625
+ } catch (e) {
1626
+ const msg = e instanceof MxError ? e.message : String(e);
1627
+ process.stderr.write(`${warn()} ${dim(`could not open layout: ${msg}`)}
1628
+ `);
1629
+ }
1630
+ }
1136
1631
  return;
1137
1632
  }
1138
1633
  if (action === "ls") {
@@ -1157,6 +1652,7 @@ function dispatchWork(positionals, flags) {
1157
1652
  const chip = w.isArchived === true ? ` ${dim(`[archived ${(w.archived_at ?? "").slice(0, 10)}]`)}` : "";
1158
1653
  const styledName = w.isArchived === true ? dim(w.name) : bold(w.name);
1159
1654
  console.log(`\u2022 ${styledName}${chip}`);
1655
+ console.log(` ${dim(tildify(w.path))}`);
1160
1656
  if (w.description) {
1161
1657
  console.log(` ${dim(`\u2014 ${w.description}`)}`);
1162
1658
  }
@@ -1294,7 +1790,7 @@ function workWorktree(root, name, positionals, flags) {
1294
1790
  case "add": {
1295
1791
  const repo = need2(
1296
1792
  positionals[3],
1297
- "usage: mx work -n <name> worktree add <repo> [--branch <b>] [--base <ref>]"
1793
+ "usage: mx work -n <name> worktree add <repo> [--branch <b>] [--base <ref>] [--no-hydrate]"
1298
1794
  );
1299
1795
  const res = worktreeAdd(root, name, repo, { branch: flags.branch, base: flags.base });
1300
1796
  emit(
@@ -1303,6 +1799,18 @@ function workWorktree(root, name, positionals, flags) {
1303
1799
  ),
1304
1800
  res
1305
1801
  );
1802
+ if (!flags.noHydrate) {
1803
+ const outcome = runWorktreeHydrate(
1804
+ { root, work: name, repo: res.repo, worktreePath: res.path, branch: res.branch, base: flags.base },
1805
+ flags.porcelain
1806
+ );
1807
+ if (outcome.ran && !outcome.ok && !flags.porcelain) {
1808
+ process.stderr.write(
1809
+ `${warn()} ${dim(`hydrate.sh for ${res.repo} exited non-zero \u2014 worktree kept. Re-run: mx work -n ${name} worktree hydrate ${res.repo}`)}
1810
+ `
1811
+ );
1812
+ }
1813
+ }
1306
1814
  return;
1307
1815
  }
1308
1816
  case "ls": {
@@ -1334,6 +1842,31 @@ function workWorktree(root, name, positionals, flags) {
1334
1842
  );
1335
1843
  return;
1336
1844
  }
1845
+ case "hydrate": {
1846
+ const repo = need2(positionals[3], "usage: mx work -n <name> worktree hydrate <repo>");
1847
+ const wt = worktreeList(root, name).find((w) => w.repo === repo);
1848
+ if (!wt) throw new MxError(`work "${name}" has no worktree for ${repo}`, "NO_WORKTREE");
1849
+ const worktreePath2 = path8.join(workPath(root, name).path, "wt", repo);
1850
+ const outcome = runWorktreeHydrate(
1851
+ { root, work: name, repo, worktreePath: worktreePath2, branch: wt.branch },
1852
+ flags.porcelain
1853
+ );
1854
+ if (outcome.missing) {
1855
+ emit(
1856
+ () => console.log(dim(`no hydrate.sh for ${repo} \u2014 nothing to run`)),
1857
+ { work: name, repo, ran: false }
1858
+ );
1859
+ return;
1860
+ }
1861
+ if (!outcome.ok) throw new MxError(`hydrate.sh for ${repo} exited non-zero`, "HYDRATE_FAILED");
1862
+ emit(() => console.log(`${check()} hydrated ${bold(repo)}`), {
1863
+ work: name,
1864
+ repo,
1865
+ ran: true,
1866
+ ok: true
1867
+ });
1868
+ return;
1869
+ }
1337
1870
  default:
1338
1871
  throw new MxError(`unknown worktree command: ${sub ?? "(none)"}`, "BAD_ARGS");
1339
1872
  }
@@ -1404,7 +1937,7 @@ function workPort(root, name, positionals) {
1404
1937
  // src/main.ts
1405
1938
  var VERSION = (() => {
1406
1939
  const here = path9.dirname(fileURLToPath2(import.meta.url));
1407
- const pkg = JSON.parse(readFileSync2(path9.join(here, "..", "package.json"), "utf8"));
1940
+ const pkg = JSON.parse(readFileSync3(path9.join(here, "..", "package.json"), "utf8"));
1408
1941
  return pkg.version;
1409
1942
  })();
1410
1943
  function main() {
@@ -1423,7 +1956,9 @@ function main() {
1423
1956
  switch (positionals[0]) {
1424
1957
  case "init":
1425
1958
  case "status":
1959
+ case "sync":
1426
1960
  case "update":
1961
+ case "migrate":
1427
1962
  return runGlobal(positionals, flags);
1428
1963
  case "repo":
1429
1964
  return dispatchRepo(positionals, flags);