yggtree 1.4.4 โ†’ 1.4.5

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/README.md CHANGED
@@ -40,8 +40,8 @@ worktree workflow first, then load the smallest reference they need:
40
40
  the work already in progress.
41
41
  * **Bootstrap and use a realm**: prepare a worktree, open it in your IDE, or
42
42
  run commands inside it.
43
- * **Run sandbox experiments**: try alternative approaches locally and
44
- apply/unapply the winner safely.
43
+ * **Run sandbox experiments**: try alternative approaches locally, then copy the
44
+ winner back with sandbox-backed apply/unapply.
45
45
 
46
46
  This skill is especially useful with agents like **Claude Code**,
47
47
  **Codex**, **Cursor**, **Gemini CLI**, and other tools that support the open
@@ -110,6 +110,7 @@ All managed worktrees live under:
110
110
  ```
111
111
 
112
112
  This keeps your main repository clean while enabling true parallelism.
113
+ You can change the global managed worktree location with `yggtree config`.
113
114
 
114
115
  ---
115
116
 
@@ -146,7 +147,7 @@ Create, manage, and navigate Git worktrees as a primary workflow, not an afterth
146
147
  Work on multiple branches at the same time, each in its own isolated environment.
147
148
 
148
149
  ๐Ÿงช **Sandbox worktrees for experimentation**
149
- Prototyping something risky? Create a sandbox, try different strategies, and apply the winner back to your origin branch.
150
+ Prototyping something risky? Create a sandbox, try different strategies, and copy the winner back to your origin checkout.
150
151
 
151
152
  ๐Ÿค **Handoff current work**
152
153
  Started in your main checkout? Carry staged, unstaged, and untracked work into a named sandbox worktree and continue there.
@@ -161,7 +162,7 @@ Run installs, submodules, and setup scripts automatically for each worktree.
161
162
  Enter worktrees, execute commands, or run tasks without changing directories.
162
163
 
163
164
  ๐Ÿ“ **Predictable structure**
164
- All managed worktrees live under `~/.yggtree`, keeping your repository clean.
165
+ Managed worktrees default to `~/.yggtree`, with an optional global path setting for agent-native layouts.
165
166
 
166
167
  ๐Ÿงญ **Interactive or scriptable**
167
168
  Use the interactive UI or drive everything through commands and flags.
@@ -217,12 +218,12 @@ Sometimes you don't want to "commit to a branch" yet. You just want to try somet
217
218
 
218
219
  1. **Create**: `yggtree create-sandbox` (creates something like `sandbox-a3f2_feature-branch`).
219
220
  2. **Experiment**: Change files, run tests, try that risky refactor.
220
- 3. **Apply**: `yggtree apply` to "push" those file changes back to your origin directory.
221
- 4. **Unapply**: Don't like it? `yggtree unapply` restores your origin to exactly how it was before.
221
+ 3. **Apply**: `yggtree apply` to copy changed sandbox files back to your origin directory with backups.
222
+ 4. **Unapply**: Don't like it? `yggtree unapply` restores those backed-up origin files while the sandbox still exists.
222
223
 
223
224
  Sandboxes are **not pushed to remote**. Omit the name for a generated temporary sandbox, or provide one when the work needs to be easy to find later.
224
225
 
225
- Use `handoff` when you started in the origin checkout and want to continue that dirty work in a sandbox:
226
+ Use `handoff` when you started in the origin checkout and want to continue that dirty work in a sandbox. This is the taught path for staged, unstaged, or untracked work:
226
227
 
227
228
  ```bash
228
229
  yggtree handoff --name auth-refactor
@@ -259,6 +260,48 @@ Resolution order:
259
260
 
260
261
  ---
261
262
 
263
+ ## ๐Ÿ—‚๏ธ Global Worktree Paths
264
+
265
+ By default, managed worktrees use the Yggtree layout:
266
+
267
+ ```bash
268
+ ~/.yggtree/<repo-name>/<worktree-slug>
269
+ ```
270
+
271
+ Show the active global setting:
272
+
273
+ ```bash
274
+ yggtree config get
275
+ ```
276
+
277
+ Use the Codex-style layout when you want Yggtree-created worktrees to live under Codex's worktree root:
278
+
279
+ ```bash
280
+ yggtree config use codex
281
+ ```
282
+
283
+ That preset creates new managed worktrees like:
284
+
285
+ ```bash
286
+ ~/.codex/worktrees/<worktree-slug>/<repo-name>
287
+ ```
288
+
289
+ Set a custom managed root while keeping the Yggtree layout:
290
+
291
+ ```bash
292
+ yggtree config set-worktrees-root ~/Worktrees
293
+ ```
294
+
295
+ Return to defaults:
296
+
297
+ ```bash
298
+ yggtree config reset
299
+ ```
300
+
301
+ Claude and Cursor do not currently have a confirmed native worktree directory pattern in Yggtree. Until that is explicit, use `set-worktrees-root` with the directory you want rather than relying on an unverified preset.
302
+
303
+ ---
304
+
262
305
  ## ๐Ÿ› ๏ธ Command Reference
263
306
 
264
307
  ### `yggtree`
@@ -349,12 +392,12 @@ yggtree wc hotfix-auth main --open --no-enter
349
392
 
350
393
  ### `yggtree create-sandbox`
351
394
 
352
- Create a temporary sandbox from your current local branch.
395
+ Create a temporary local-only sandbox from your current branch.
353
396
 
354
397
  Options:
355
398
 
356
399
  * `-n, --name <name>`: Optional sandbox name (auto-generated if omitted).
357
- * `--carry / --no-carry`: Bring uncommitted changes (staged/unstaged/untracked) with you.
400
+ * `--carry / --no-carry`: Explicitly choose whether to copy uncommitted changes into the sandbox. Prefer `handoff` when the intent is continuing dirty work.
358
401
  * `--no-bootstrap`
359
402
  * `--open / --no-open`
360
403
  * `--exec "<command>"`
@@ -378,29 +421,32 @@ Options:
378
421
  * `--open / --no-open`
379
422
  * `--exec "<command>"`
380
423
 
381
- This is the continuation-focused version of `create-sandbox --carry`: it keeps sandbox metadata and apply/unapply behavior, but defaults to carrying staged, unstaged, and untracked files.
424
+ This is the continuation-focused semantic path for dirty work. It uses sandbox metadata and apply/unapply behavior, but defaults to carrying staged, unstaged, and untracked files.
382
425
 
383
426
  ---
384
427
 
385
428
  ### `yggtree apply`
386
429
 
387
- Apply changes from the current sandbox back to the origin repository.
388
- * **Backs up** origin files before overwriting.
430
+ Copy changed files from the current sandbox back to the origin checkout.
431
+ * **Backs up** origin files in sandbox metadata before overwriting.
389
432
  * **Offers to delete** the sandbox after applying.
433
+ * **Is not a Git merge**: review the origin diff after applying, especially when deletions are involved.
390
434
 
391
435
  ---
392
436
 
393
437
  ### `yggtree unapply`
394
438
 
395
439
  Undo a previous `apply` operation.
396
- * Restores origin files from the sandbox's backup.
440
+ * Restores origin files from the sandbox's backup metadata.
397
441
  * *Note: Only works if the sandbox worktree still exists.*
398
442
 
399
443
  ---
400
444
 
401
445
  ### `yggtree create-multi`
402
446
 
403
- Create multiple worktrees at once.
447
+ Create multiple official branch-backed worktrees at once. This is a bulk setup
448
+ command; it does not have the full `create` lifecycle flags such as `--open`,
449
+ `--enter`, or `--exec`.
404
450
 
405
451
  Options:
406
452
 
@@ -434,7 +480,7 @@ Columns:
434
480
  Notes:
435
481
 
436
482
  * Entries are grouped by `TYPE`.
437
- * `SANDBOX` and `MANAGED` are worktrees inside `~/.yggtree`.
483
+ * `SANDBOX` and `MANAGED` are worktrees inside the configured managed worktree root.
438
484
  * External worktrees are labeled `LINKED`.
439
485
  * Use `--open` to switch this flow into "pick and open in tool" mode.
440
486
  * The **PR** column shows the pull request status for each branch (e.g. `OPEN`, `IN REVIEW`, `APPROVED`, `MERGED`, `DRAFT`, `CHANGES`). It only appears when `gh` CLI is installed and authenticated โ€” otherwise it's silently omitted.
@@ -449,7 +495,7 @@ Behavior:
449
495
 
450
496
  * If `[worktree]` is omitted, you can pick from the worktree list with type-to-filter search.
451
497
  * Detects available editor commands in your `PATH` (for example: `cursor`, `code`, `zed`, `webstorm`).
452
- * Detects Codex App on macOS and launches it with `open -b com.openai.codex`.
498
+ * Detects Codex App on macOS and launches the selected worktree with `codex app <path>`.
453
499
  * Detects Cmux and Tmux when their CLI commands are available.
454
500
  * Lets you choose one editor, app, or terminal target interactively, or pass `--tool`.
455
501
  * Keeps Cmux, Tmux, and `Other command...` mutually exclusive by using a single action picker.
@@ -517,7 +563,7 @@ Behavior:
517
563
 
518
564
  Optional:
519
565
 
520
- * `--all` includes linked worktrees outside `~/.yggtree` (main/current worktree is excluded for safety)
566
+ * `--all` includes linked worktrees outside the configured managed root (main/current worktree is excluded for safety)
521
567
 
522
568
  ---
523
569
 
@@ -661,7 +707,7 @@ Useful when you want to manually navigate or copy the path into scripts.
661
707
  **Command:**
662
708
 
663
709
  ```bash
664
- yggtree create-sandbox --carry
710
+ yggtree handoff --name risky-refactor
665
711
  ```
666
712
 
667
713
  **Scenario:**
@@ -670,7 +716,7 @@ yggtree create-sandbox --carry
670
716
  2. Run `handoff --name risky-refactor` to carry those changes into an isolated `sandbox-risky-refactor` folder.
671
717
  3. Experiment freely.
672
718
  4. If it works: `yggtree apply`.
673
- 5. If it fails: Just delete the sandbox or `unapply`.
719
+ 5. If it fails after applying: run `yggtree unapply` before deleting the sandbox.
674
720
 
675
721
  </details>
676
722
 
@@ -0,0 +1,53 @@
1
+ import chalk from 'chalk';
2
+ import { formatGlobalConfig, getPresetConfig, getWorktreePathConfig, normalizeWorktreesRootInput, readGlobalConfig, writeGlobalConfig, } from '../lib/global-config.js';
3
+ import { log } from '../lib/ui.js';
4
+ export async function configGetCommand() {
5
+ const [rawConfig, resolvedConfig] = await Promise.all([
6
+ readGlobalConfig(),
7
+ getWorktreePathConfig(),
8
+ ]);
9
+ console.log(chalk.bold('\n Yggtree config\n'));
10
+ formatGlobalConfig(rawConfig, resolvedConfig)
11
+ .forEach(line => console.log(` ${line}`));
12
+ console.log('');
13
+ }
14
+ export async function configUseCommand(preset) {
15
+ const presetConfig = getPresetConfig(preset);
16
+ if (!presetConfig) {
17
+ log.error(`Unknown config preset "${preset}".`);
18
+ log.dim('Available presets: default, yggtree, codex');
19
+ return;
20
+ }
21
+ await writeGlobalConfig(presetConfig);
22
+ log.success(`Using ${preset} worktree layout.`);
23
+ await configGetCommand();
24
+ }
25
+ export async function configSetWorktreesRootCommand(root) {
26
+ const currentConfig = await readGlobalConfig();
27
+ await writeGlobalConfig({
28
+ ...currentConfig,
29
+ worktreesRoot: normalizeWorktreesRootInput(root),
30
+ worktreeLayout: currentConfig.worktreeLayout || 'yggtree',
31
+ });
32
+ log.success('Updated worktrees root.');
33
+ await configGetCommand();
34
+ }
35
+ export async function configSetWorktreeLayoutCommand(layout) {
36
+ if (layout !== 'yggtree' && layout !== 'codex') {
37
+ log.error(`Unknown worktree layout "${layout}".`);
38
+ log.dim('Available layouts: yggtree, codex');
39
+ return;
40
+ }
41
+ const currentConfig = await readGlobalConfig();
42
+ await writeGlobalConfig({
43
+ ...currentConfig,
44
+ worktreeLayout: layout,
45
+ });
46
+ log.success('Updated worktree layout.');
47
+ await configGetCommand();
48
+ }
49
+ export async function configResetCommand() {
50
+ await writeGlobalConfig({});
51
+ log.success('Reset Yggtree config to defaults.');
52
+ await configGetCommand();
53
+ }
@@ -2,23 +2,25 @@ import chalk from 'chalk';
2
2
  import inquirer from 'inquirer';
3
3
  import path from 'path';
4
4
  import { listWorktrees, getRepoRoot } from '../../lib/git.js';
5
- import { WORKTREES_ROOT } from '../../lib/paths.js';
5
+ import { getManagedWorktreesRoot } from '../../lib/global-config.js';
6
6
  import { log } from '../../lib/ui.js';
7
7
  import { runBootstrap } from '../../lib/config.js';
8
+ import { isManagedWorktreePath } from '../../lib/worktree.js';
8
9
  export async function bootstrapCommand() {
9
10
  try {
10
11
  const _ = await getRepoRoot();
11
12
  const worktrees = await listWorktrees();
13
+ const managedRoot = await getManagedWorktreesRoot();
12
14
  // 1. Select Worktree
13
15
  // Filter managed or just show all? The prompt said "managed", let's prioritize managed but maybe allow all if needed?
14
16
  // User requirements say "managed", sticking to that for consistency.
15
- const managedWts = worktrees.filter(wt => wt.path.startsWith(WORKTREES_ROOT));
17
+ const managedWts = worktrees.filter(wt => isManagedWorktreePath(wt.path, managedRoot));
16
18
  if (managedWts.length === 0) {
17
19
  log.info('No managed worktrees found to bootstrap.');
18
20
  return;
19
21
  }
20
22
  const choices = managedWts.map(wt => {
21
- const relative = path.relative(WORKTREES_ROOT, wt.path);
23
+ const relative = path.relative(managedRoot, wt.path);
22
24
  return {
23
25
  name: `${chalk.bold(relative)} (${chalk.dim(wt.branch || wt.HEAD)})`,
24
26
  value: wt.path,
@@ -3,7 +3,7 @@ import inquirer from 'inquirer';
3
3
  import path from 'path';
4
4
  import { getRepoRoot, getRepoName, verifyRef, fetchAll, getCurrentBranch, ensureCorrectUpstream, publishBranch } from '../../lib/git.js';
5
5
  import { runBootstrap } from '../../lib/config.js';
6
- import { WORKTREES_ROOT } from '../../lib/paths.js';
6
+ import { buildManagedWorktreePath, getWorktreePathConfig } from '../../lib/global-config.js';
7
7
  import { log, ui, createSpinner } from '../../lib/ui.js';
8
8
  import { promptAndCopyEnvFiles } from '../../lib/env-files.js';
9
9
  import { detectInstalledOpenTools, launchOpenTool, promptOpenToolSelection, } from './open.js';
@@ -89,7 +89,8 @@ export async function createCommandNew(options) {
89
89
  // e.g. feat/new-button -> feat-new-button
90
90
  const slug = branchName.replace(/[\/\\]/g, '-').replace(/\s+/g, '-');
91
91
  const repoName = await getRepoName();
92
- const wtPath = path.join(WORKTREES_ROOT, repoName, slug);
92
+ const worktreePathConfig = await getWorktreePathConfig();
93
+ const wtPath = buildManagedWorktreePath(repoName, slug, worktreePathConfig);
93
94
  // 2. Validation
94
95
  if (!slug)
95
96
  throw new Error('Invalid name');
@@ -3,7 +3,7 @@ import inquirer from 'inquirer';
3
3
  import path from 'path';
4
4
  import { getRepoRoot, getRepoName, verifyRef, fetchAll, getCurrentBranch, ensureCorrectUpstream, publishBranch } from '../../lib/git.js';
5
5
  import { runBootstrap } from '../../lib/config.js';
6
- import { WORKTREES_ROOT } from '../../lib/paths.js';
6
+ import { buildManagedWorktreePath, getWorktreePathConfig } from '../../lib/global-config.js';
7
7
  import { log, ui, createSpinner } from '../../lib/ui.js';
8
8
  import { findLocalEnvFiles, promptAndCopyEnvFilesToWorktrees } from '../../lib/env-files.js';
9
9
  import { execa } from 'execa';
@@ -70,9 +70,10 @@ export async function createCommandMulti(options) {
70
70
  const createdWorktrees = [];
71
71
  // 3. Execution for each branch
72
72
  const repoName = await getRepoName();
73
+ const worktreePathConfig = await getWorktreePathConfig();
73
74
  for (const branchName of branchNames) {
74
75
  const slug = branchName.replace(/[\/\\]/g, '-').replace(/\s+/g, '-');
75
- const wtPath = path.join(WORKTREES_ROOT, repoName, slug);
76
+ const wtPath = buildManagedWorktreePath(repoName, slug, worktreePathConfig);
76
77
  log.header(`Processing: ${branchName}`);
77
78
  const wtSpinner = createSpinner(`Creating worktree at ${ui.path(wtPath)}...`).start();
78
79
  // Check if target branch already exists
@@ -3,7 +3,7 @@ import inquirer from 'inquirer';
3
3
  import path from 'path';
4
4
  import { getRepoRoot, getRepoName, getCurrentBranch } from '../../lib/git.js';
5
5
  import { runBootstrap } from '../../lib/config.js';
6
- import { WORKTREES_ROOT } from '../../lib/paths.js';
6
+ import { buildManagedWorktreePath, getWorktreePathConfig } from '../../lib/global-config.js';
7
7
  import { log, ui, createSpinner } from '../../lib/ui.js';
8
8
  import { generateSandboxName, normalizeSandboxName, writeSandboxMeta } from '../../lib/sandbox.js';
9
9
  import { promptAndCopyEnvFiles } from '../../lib/env-files.js';
@@ -136,7 +136,8 @@ export async function createSandboxCommand(options = {}) {
136
136
  }
137
137
  // 5. Create worktree
138
138
  const repoName = await getRepoName();
139
- const wtPath = path.join(WORKTREES_ROOT, repoName, sandboxName);
139
+ const worktreePathConfig = await getWorktreePathConfig();
140
+ const wtPath = buildManagedWorktreePath(repoName, sandboxName, worktreePathConfig);
140
141
  const spinner = createSpinner('Creating sandbox worktree...').start();
141
142
  try {
142
143
  await fs.ensureDir(path.dirname(wtPath));
@@ -3,7 +3,7 @@ import inquirer from 'inquirer';
3
3
  import path from 'path';
4
4
  import { getRepoName, createWorktree, fetchAll, listWorktrees } from '../../lib/git.js';
5
5
  import { runBootstrap } from '../../lib/config.js';
6
- import { WORKTREES_ROOT } from '../../lib/paths.js';
6
+ import { buildManagedWorktreePath, getWorktreePathConfig } from '../../lib/global-config.js';
7
7
  import { log, ui, createSpinner } from '../../lib/ui.js';
8
8
  import { ensureAutocompletePrompt } from '../../lib/prompt.js';
9
9
  import { promptAndCopyEnvFiles } from '../../lib/env-files.js';
@@ -204,7 +204,8 @@ export async function createCommand(options) {
204
204
  // 3. Gather remaining inputs
205
205
  const defaultSlug = toSlug(selectedBranch.branchName);
206
206
  const repoName = await getRepoName();
207
- const resolveWorktreePath = (name) => path.join(WORKTREES_ROOT, repoName, toSlug(name));
207
+ const worktreePathConfig = await getWorktreePathConfig();
208
+ const resolveWorktreePath = (name) => buildManagedWorktreePath(repoName, toSlug(name), worktreePathConfig);
208
209
  const answers = await inquirer.prompt([
209
210
  {
210
211
  type: 'input',
@@ -1,19 +1,21 @@
1
1
  import chalk from 'chalk';
2
2
  import inquirer from 'inquirer';
3
3
  import { listWorktrees, removeWorktree, getRepoRoot, getLastActivity } from '../../lib/git.js';
4
+ import { getManagedWorktreesRoot } from '../../lib/global-config.js';
4
5
  import { log, createSpinner, timeAgo } from '../../lib/ui.js';
5
6
  import { detectWorktreeType, formatWorktreeDisplayPath, formatWorktreeType, getWorktreeBranchName, isManagedWorktreePath, } from '../../lib/worktree.js';
6
7
  export async function deleteCommand(options = {}) {
7
8
  try {
8
9
  const currentWorktreePath = await getRepoRoot();
9
10
  const worktrees = await listWorktrees();
11
+ const managedRoot = await getManagedWorktreesRoot();
10
12
  let showAll = options.all;
11
13
  if (showAll === undefined) {
12
14
  const { includeExternal } = await inquirer.prompt([
13
15
  {
14
16
  type: 'confirm',
15
17
  name: 'includeExternal',
16
- message: 'Include external linked worktrees (outside ~/.yggtree)?',
18
+ message: 'Include external linked worktrees (outside the managed root)?',
17
19
  default: false,
18
20
  },
19
21
  ]);
@@ -27,7 +29,7 @@ export async function deleteCommand(options = {}) {
27
29
  const isCurrentWorktree = wt.path === currentWorktreePath;
28
30
  return !isMainWorktree && !isCurrentWorktree;
29
31
  }
30
- return isManagedWorktreePath(wt.path);
32
+ return isManagedWorktreePath(wt.path, managedRoot);
31
33
  });
32
34
  if (deletableWts.length === 0) {
33
35
  log.info(includeAll
@@ -38,11 +40,11 @@ export async function deleteCommand(options = {}) {
38
40
  const choices = await Promise.all(deletableWts.map(async (wt) => {
39
41
  const [activity, type] = await Promise.all([
40
42
  getLastActivity(wt.path),
41
- detectWorktreeType(wt, mainWorktreePath || ''),
43
+ detectWorktreeType(wt, mainWorktreePath || '', managedRoot),
42
44
  ]);
43
45
  const branchName = getWorktreeBranchName(wt);
44
46
  const active = activity ? chalk.magenta(timeAgo(activity)) : chalk.dim('โ€”');
45
- const displayPath = formatWorktreeDisplayPath(wt.path);
47
+ const displayPath = formatWorktreeDisplayPath(wt.path, managedRoot);
46
48
  return {
47
49
  name: `${formatWorktreeType(type)} ${chalk.bold.yellow(branchName)} ${chalk.dim('ยท')} ${active} ${chalk.dim('ยท')} ${chalk.dim(displayPath)}`,
48
50
  value: wt.path,
@@ -62,7 +64,7 @@ export async function deleteCommand(options = {}) {
62
64
  return;
63
65
  }
64
66
  const count = selectedPaths.length;
65
- const names = selectedPaths.map((p) => formatWorktreeDisplayPath(p));
67
+ const names = selectedPaths.map((p) => formatWorktreeDisplayPath(p, managedRoot));
66
68
  const { confirm } = await inquirer.prompt([
67
69
  {
68
70
  type: 'confirm',
@@ -76,7 +78,7 @@ export async function deleteCommand(options = {}) {
76
78
  return;
77
79
  }
78
80
  for (const wtPath of selectedPaths) {
79
- const worktreeName = formatWorktreeDisplayPath(wtPath);
81
+ const worktreeName = formatWorktreeDisplayPath(wtPath, managedRoot);
80
82
  const spinner = createSpinner(`Deleting ${worktreeName}...`).start();
81
83
  try {
82
84
  await removeWorktree(wtPath);
@@ -3,6 +3,7 @@ import { spawn } from 'child_process';
3
3
  import { execa } from 'execa';
4
4
  import chalk from 'chalk';
5
5
  import { listWorktrees } from '../../lib/git.js';
6
+ import { getManagedWorktreesRoot } from '../../lib/global-config.js';
6
7
  import { log, ui } from '../../lib/ui.js';
7
8
  import { ensureRepoContext } from '../../lib/repo-context.js';
8
9
  import { detectWorktreeType, findWorktreeByName, formatWorktreeDisplayPath, formatWorktreeType, getWorktreeBranchName, } from '../../lib/worktree.js';
@@ -40,13 +41,14 @@ export async function enterCommand(wtName, options = {}) {
40
41
  await ensureRepoContext();
41
42
  const worktrees = await listWorktrees();
42
43
  const mainWorktreePath = worktrees[0]?.path || '';
44
+ const managedRoot = await getManagedWorktreesRoot();
43
45
  if (worktrees.length === 0) {
44
46
  log.info('No worktrees found.');
45
47
  return;
46
48
  }
47
49
  let targetWt;
48
50
  if (wtName) {
49
- targetWt = findWorktreeByName(worktrees, wtName);
51
+ targetWt = findWorktreeByName(worktrees, wtName, managedRoot);
50
52
  if (!targetWt) {
51
53
  log.error(`Worktree "${wtName}" not found.`);
52
54
  return;
@@ -56,9 +58,9 @@ export async function enterCommand(wtName, options = {}) {
56
58
  // Interactive Selection
57
59
  const terminalColumns = process.stdout.columns || 100;
58
60
  const choices = await Promise.all(worktrees.map(async (wt) => {
59
- const type = await detectWorktreeType(wt, mainWorktreePath);
61
+ const type = await detectWorktreeType(wt, mainWorktreePath, managedRoot);
60
62
  const branchName = getWorktreeBranchName(wt);
61
- const displayPath = formatWorktreeDisplayPath(wt.path);
63
+ const displayPath = formatWorktreeDisplayPath(wt.path, managedRoot);
62
64
  return {
63
65
  name: formatChoiceLabel(type, branchName, displayPath, terminalColumns),
64
66
  value: wt
@@ -1,6 +1,7 @@
1
1
  import inquirer from 'inquirer';
2
2
  import { execa } from 'execa';
3
3
  import { listWorktrees, getRepoRoot } from '../../lib/git.js';
4
+ import { getManagedWorktreesRoot } from '../../lib/global-config.js';
4
5
  import { log } from '../../lib/ui.js';
5
6
  import { findWorktreeByName, formatWorktreeDisplayPath, getWorktreeBranchName } from '../../lib/worktree.js';
6
7
  export async function execCommand(wtName, commandArgs) {
@@ -10,12 +11,13 @@ export async function execCommand(wtName, commandArgs) {
10
11
  try {
11
12
  await getRepoRoot();
12
13
  const worktrees = await listWorktrees();
14
+ const managedRoot = await getManagedWorktreesRoot();
13
15
  if (worktrees.length === 0) {
14
16
  log.info('No worktrees found.');
15
17
  return;
16
18
  }
17
19
  if (wtName) {
18
- targetWt = findWorktreeByName(worktrees, wtName);
20
+ targetWt = findWorktreeByName(worktrees, wtName, managedRoot);
19
21
  if (!targetWt) {
20
22
  log.error(`Worktree "${wtName}" not found.`);
21
23
  return;
@@ -25,7 +27,7 @@ export async function execCommand(wtName, commandArgs) {
25
27
  // Interactive Selection
26
28
  const choices = worktrees.map(wt => {
27
29
  const branchName = getWorktreeBranchName(wt);
28
- const displayPath = formatWorktreeDisplayPath(wt.path);
30
+ const displayPath = formatWorktreeDisplayPath(wt.path, managedRoot);
29
31
  return {
30
32
  name: `${branchName.padEnd(20)} ${displayPath}`,
31
33
  value: wt
@@ -1,5 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import { listWorktrees, getRepoRoot, isGitClean, getLastActivity, isGhAvailable, getPrStatusBatch } from '../../lib/git.js';
3
+ import { getManagedWorktreesRoot } from '../../lib/global-config.js';
3
4
  import { log, timeAgo } from '../../lib/ui.js';
4
5
  import { detectWorktreeType, formatWorktreeType, formatPrStatus, getWorktreeBranchName, WORKTREE_TYPE_ORDER, } from '../../lib/worktree.js';
5
6
  export async function listCommand() {
@@ -7,6 +8,7 @@ export async function listCommand() {
7
8
  await getRepoRoot(); // Verify we are in a git repo
8
9
  const worktrees = await listWorktrees();
9
10
  const mainWorktreePath = worktrees[0]?.path || '';
11
+ const managedRoot = await getManagedWorktreesRoot();
10
12
  if (worktrees.length === 0) {
11
13
  log.info('No worktrees found.');
12
14
  return;
@@ -24,7 +26,7 @@ export async function listCommand() {
24
26
  console.log(chalk.dim(' ' + '-'.repeat(showPr ? 90 : 70)));
25
27
  const rows = await Promise.all(worktrees.map(async (wt, index) => {
26
28
  const [typeKey, isClean, lastActive] = await Promise.all([
27
- detectWorktreeType(wt, mainWorktreePath),
29
+ detectWorktreeType(wt, mainWorktreePath, managedRoot),
28
30
  isGitClean(wt.path),
29
31
  getLastActivity(wt.path),
30
32
  ]);
@@ -5,6 +5,7 @@ import fs from 'fs-extra';
5
5
  import { constants as fsConstants } from 'fs';
6
6
  import { execa } from 'execa';
7
7
  import { listWorktrees, getRepoRoot } from '../../lib/git.js';
8
+ import { getManagedWorktreesRoot } from '../../lib/global-config.js';
8
9
  import { log, ui } from '../../lib/ui.js';
9
10
  import { ensureAutocompletePrompt } from '../../lib/prompt.js';
10
11
  import { detectWorktreeType, findWorktreeByName, formatWorktreeDisplayPath, formatWorktreeType, getWorktreeBranchName, } from '../../lib/worktree.js';
@@ -24,6 +25,7 @@ export const OPEN_TOOL_CANDIDATES = [
24
25
  kind: 'app',
25
26
  aliases: ['codex', 'codex-app'],
26
27
  bundleId: 'com.openai.codex',
28
+ requiredCommand: 'codex',
27
29
  },
28
30
  { id: 'cmux', name: 'Cmux Panel', command: 'cmux', kind: 'terminal', aliases: ['cmux', 'cmux-panel'] },
29
31
  { id: 'tmux', name: 'Tmux Session', command: 'tmux', kind: 'terminal', aliases: ['tmux', 'tmux-session'] },
@@ -175,16 +177,27 @@ export async function macOSAppBundleExists(bundleId) {
175
177
  export async function detectInstalledOpenTools() {
176
178
  const checks = await Promise.all(OPEN_TOOL_CANDIDATES.map(async (tool) => ({
177
179
  tool,
178
- exists: tool.bundleId
179
- ? await macOSAppBundleExists(tool.bundleId)
180
- : await commandExists(tool.command),
180
+ exists: await isOpenToolAvailable(tool),
181
181
  })));
182
182
  return checks
183
183
  .filter(check => check.exists)
184
184
  .map(check => check.tool);
185
185
  }
186
- function resolveWorktreeByName(worktrees, wtName) {
187
- return findWorktreeByName(worktrees, wtName);
186
+ export async function isOpenToolAvailable(tool, checks = {}) {
187
+ const hasCommand = checks.commandExists || commandExists;
188
+ const hasAppBundle = checks.macOSAppBundleExists || macOSAppBundleExists;
189
+ if (tool.requiredCommand) {
190
+ const commandAvailable = await hasCommand(tool.requiredCommand);
191
+ if (!commandAvailable)
192
+ return false;
193
+ }
194
+ if (tool.bundleId) {
195
+ return hasAppBundle(tool.bundleId);
196
+ }
197
+ return hasCommand(tool.command);
198
+ }
199
+ function resolveWorktreeByName(worktrees, wtName, managedRoot) {
200
+ return findWorktreeByName(worktrees, wtName, managedRoot);
188
201
  }
189
202
  export function resolveOpenToolOption(input, installed) {
190
203
  const normalized = input.trim().toLowerCase();
@@ -202,9 +215,7 @@ async function resolveKnownOpenToolOption(input) {
202
215
  const candidate = resolveOpenToolCandidate(input);
203
216
  if (!candidate)
204
217
  return undefined;
205
- const exists = candidate.bundleId
206
- ? await macOSAppBundleExists(candidate.bundleId)
207
- : await commandExists(candidate.command);
218
+ const exists = await isOpenToolAvailable(candidate);
208
219
  return exists ? candidate : undefined;
209
220
  }
210
221
  export async function resolveOpenToolAction(input, installed) {
@@ -331,6 +342,12 @@ export async function launchOpenTool(tool, wtPath) {
331
342
  });
332
343
  }
333
344
  export function buildOpenToolLaunchCommand(tool, wtPath) {
345
+ if (tool.id === 'codex-app') {
346
+ return {
347
+ executable: 'codex',
348
+ args: ['app', wtPath],
349
+ };
350
+ }
334
351
  if (tool.bundleId) {
335
352
  return {
336
353
  executable: '/usr/bin/open',
@@ -367,13 +384,14 @@ export async function openCommand(wtName, options = {}) {
367
384
  await getRepoRoot();
368
385
  const worktrees = await listWorktrees();
369
386
  const mainWorktreePath = worktrees[0]?.path || '';
387
+ const managedRoot = await getManagedWorktreesRoot();
370
388
  if (worktrees.length === 0) {
371
389
  log.info('No worktrees found.');
372
390
  return;
373
391
  }
374
392
  let targetWt;
375
393
  if (wtName) {
376
- targetWt = resolveWorktreeByName(worktrees, wtName);
394
+ targetWt = resolveWorktreeByName(worktrees, wtName, managedRoot);
377
395
  if (!targetWt) {
378
396
  log.error(`Worktree "${wtName}" not found.`);
379
397
  return;
@@ -383,9 +401,9 @@ export async function openCommand(wtName, options = {}) {
383
401
  ensureAutocompletePrompt();
384
402
  const terminalColumns = process.stdout.columns || 100;
385
403
  const candidates = await Promise.all(worktrees.map(async (wt) => {
386
- const type = await detectWorktreeType(wt, mainWorktreePath);
404
+ const type = await detectWorktreeType(wt, mainWorktreePath, managedRoot);
387
405
  const branchName = getWorktreeBranchName(wt);
388
- const displayPath = formatWorktreeDisplayPath(wt.path);
406
+ const displayPath = formatWorktreeDisplayPath(wt.path, managedRoot);
389
407
  const rawType = type.toLowerCase();
390
408
  const rawDisplayPath = displayPath.toLowerCase();
391
409
  const rawBranchName = branchName.toLowerCase();
@@ -1,18 +1,20 @@
1
1
  import inquirer from 'inquirer';
2
2
  import { listWorktrees, getRepoRoot } from '../../lib/git.js';
3
+ import { getManagedWorktreesRoot } from '../../lib/global-config.js';
3
4
  import { log } from '../../lib/ui.js';
4
5
  import { findWorktreeByName, formatWorktreeDisplayPath, getWorktreeBranchName } from '../../lib/worktree.js';
5
6
  export async function pathCommand(wtName) {
6
7
  try {
7
8
  await getRepoRoot();
8
9
  const worktrees = await listWorktrees();
10
+ const managedRoot = await getManagedWorktreesRoot();
9
11
  if (worktrees.length === 0) {
10
12
  log.info('No worktrees found.');
11
13
  return;
12
14
  }
13
15
  let targetWt;
14
16
  if (wtName) {
15
- targetWt = findWorktreeByName(worktrees, wtName);
17
+ targetWt = findWorktreeByName(worktrees, wtName, managedRoot);
16
18
  if (!targetWt) {
17
19
  log.error(`Worktree "${wtName}" not found.`);
18
20
  return;
@@ -22,7 +24,7 @@ export async function pathCommand(wtName) {
22
24
  // Interactive Selection
23
25
  const choices = worktrees.map(wt => {
24
26
  const branchName = getWorktreeBranchName(wt);
25
- const displayPath = formatWorktreeDisplayPath(wt.path);
27
+ const displayPath = formatWorktreeDisplayPath(wt.path, managedRoot);
26
28
  return {
27
29
  name: `${branchName.padEnd(20)} ${displayPath}`,
28
30
  value: wt
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { Command, Option } from 'commander';
2
2
  import inquirer from 'inquirer';
3
3
  import chalk from 'chalk';
4
- import { welcome, log } from './lib/ui.js';
4
+ import { formatMainMenuChoice, renderMainMenuIntro, welcome, log, } from './lib/ui.js';
5
+ import { applyHelp, checkoutHelp, createHelp, createMultiHelp, createSandboxHelp, handoffHelp, intentRouterHelp, openHelp, unapplyHelp, } from './lib/help.js';
5
6
  import { listCommand } from './commands/wt/list.js';
6
7
  import { createCommand } from './commands/wt/create.js';
7
8
  import { createCommandNew } from './commands/wt/create-branch.js';
@@ -16,13 +17,23 @@ import { openCommand } from './commands/wt/open.js';
16
17
  import { applyCommand } from './commands/wt/apply.js';
17
18
  import { unapplyCommand } from './commands/wt/unapply.js';
18
19
  import { handoffCommand } from './commands/wt/handoff.js';
20
+ import { configGetCommand, configResetCommand, configSetWorktreeLayoutCommand, configSetWorktreesRootCommand, configUseCommand, } from './commands/config.js';
19
21
  import { getVersion } from './lib/version.js';
20
- import { notifyIfUpdateAvailable } from './lib/update-check.js';
22
+ import { checkForUpdate } from './lib/update-check.js';
21
23
  import { findSandboxRoot } from './lib/sandbox.js';
22
24
  import { bifrostCommand } from './commands/bifrost.js';
23
25
  import { thorCommand } from './commands/thor.js';
24
26
  const program = new Command();
25
27
  const argv = process.argv.map((arg) => arg === '-v' || arg === 'โ€”version' ? '--version' : arg);
28
+ function mainMenuChoice(value, glyph, label, detail, tone) {
29
+ return {
30
+ name: formatMainMenuChoice({ glyph, label, detail, tone }),
31
+ value,
32
+ };
33
+ }
34
+ function mainMenuSeparator(label) {
35
+ return new inquirer.Separator(chalk.dim(` ยท ${label}`));
36
+ }
26
37
  function registerWorktreeCommands(parent) {
27
38
  parent.command('list')
28
39
  .description('List all repo-linked worktrees')
@@ -35,7 +46,7 @@ function registerWorktreeCommands(parent) {
35
46
  await listCommand();
36
47
  });
37
48
  parent.command('create [branch]')
38
- .description('Create a new worktree (Smart branch detection)')
49
+ .description('Create an official branch-backed task worktree')
39
50
  .option('-b, --branch <name>', 'Branch name (e.g. feat/new-ui)')
40
51
  .option('--base <ref>', 'Base ref (e.g. main)')
41
52
  .option('--source <type>', 'Base source (local or remote)')
@@ -45,6 +56,7 @@ function registerWorktreeCommands(parent) {
45
56
  .option('--enter', 'Enter the worktree sub-shell after creation')
46
57
  .option('--no-enter', 'Do not enter the worktree sub-shell after creation')
47
58
  .option('--exec <command>', 'Command to execute after creation')
59
+ .addHelpText('after', createHelp)
48
60
  .action(async (branch, options) => {
49
61
  await createCommandNew({
50
62
  ...options,
@@ -52,10 +64,11 @@ function registerWorktreeCommands(parent) {
52
64
  });
53
65
  });
54
66
  parent.command('create-multi')
55
- .description('Create multiple worktrees (Smart branch detection)')
67
+ .description('Bulk-create official branch-backed worktrees')
56
68
  .option('--base <ref>', 'Base ref (e.g. main)')
57
69
  .option('--source <type>', 'Base source (local or remote)')
58
70
  .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
71
+ .addHelpText('after', createMultiHelp)
59
72
  .action(async (options) => {
60
73
  await createCommandMulti(options);
61
74
  });
@@ -71,6 +84,7 @@ function registerWorktreeCommands(parent) {
71
84
  .addOption(new Option('--enter', 'Enter the worktree sub-shell after checkout/opening').hideHelp())
72
85
  .option('--no-enter', 'Do not enter the worktree sub-shell after checkout/opening')
73
86
  .option('--exec <command>', 'Command to execute after creation')
87
+ .addHelpText('after', checkoutHelp)
74
88
  .action(async (name, ref, options) => {
75
89
  await createCommand({
76
90
  ...options,
@@ -83,7 +97,7 @@ function registerWorktreeCommands(parent) {
83
97
  registerWorktreeCheckout('wc', 'Alias for worktree-checkout');
84
98
  parent.command('delete')
85
99
  .description('Delete managed worktrees')
86
- .option('-a, --all', 'Include repo-linked worktrees outside ~/.yggtree (except main/current)')
100
+ .option('-a, --all', 'Include repo-linked worktrees outside the managed root (except main/current)')
87
101
  .action(async (options) => {
88
102
  await deleteCommand(options);
89
103
  });
@@ -92,6 +106,7 @@ function registerWorktreeCommands(parent) {
92
106
  .option('--tool <command>', 'Editor, app, or terminal command to use (e.g. cursor, code, codex-app, tmux)')
93
107
  .option('--enter', 'Enter the worktree sub-shell after opening')
94
108
  .addOption(new Option('--no-enter', 'Do not enter the worktree sub-shell after opening').hideHelp())
109
+ .addHelpText('after', openHelp)
95
110
  .action(async (worktree, options) => {
96
111
  await openCommand(worktree, options);
97
112
  });
@@ -114,21 +129,22 @@ function registerWorktreeCommands(parent) {
114
129
  await pathCommand(worktree);
115
130
  });
116
131
  parent.command('create-sandbox')
117
- .description('Create a sandbox worktree from current branch')
132
+ .description('Create a local-only disposable experiment sandbox')
118
133
  .option('-n, --name <name>', 'Optional sandbox name (auto-generated when omitted)')
119
- .option('--carry', 'Carry uncommitted changes to sandbox')
120
- .option('--no-carry', 'Do not carry uncommitted changes')
134
+ .option('--carry', 'Copy uncommitted changes to sandbox')
135
+ .option('--no-carry', 'Do not copy uncommitted changes')
121
136
  .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
122
137
  .option('--open', 'Open an editor after creation')
123
138
  .option('--no-open', 'Skip opening an editor after creation')
124
139
  .addOption(new Option('--enter', 'Deprecated alias for --open').hideHelp())
125
140
  .addOption(new Option('--no-enter', 'Deprecated alias for --no-open').hideHelp())
126
141
  .option('--exec <command>', 'Command to execute after creation')
142
+ .addHelpText('after', createSandboxHelp)
127
143
  .action(async (options) => {
128
144
  await createSandboxCommand(options);
129
145
  });
130
146
  parent.command('handoff')
131
- .description('Carry uncommitted work into a sandbox worktree')
147
+ .description('Carry dirty current work into a named sandbox')
132
148
  .option('-n, --name <name>', 'Optional handoff name (prompted when omitted)')
133
149
  .option('--no-bootstrap', 'Skip bootstrap (npm install + submodules)')
134
150
  .option('--open', 'Open an editor after creation')
@@ -136,14 +152,17 @@ function registerWorktreeCommands(parent) {
136
152
  .addOption(new Option('--enter', 'Deprecated alias for --open').hideHelp())
137
153
  .addOption(new Option('--no-enter', 'Deprecated alias for --no-open').hideHelp())
138
154
  .option('--exec <command>', 'Command to execute after creation')
155
+ .addHelpText('after', handoffHelp)
139
156
  .action(async (options) => {
140
157
  await handoffCommand(options);
141
158
  });
142
159
  parent.command('apply')
143
- .description('Apply sandbox changes to origin directory')
160
+ .description('Copy sandbox file changes to the origin checkout')
161
+ .addHelpText('after', applyHelp)
144
162
  .action(applyCommand);
145
163
  parent.command('unapply')
146
- .description('Undo applied sandbox changes in origin')
164
+ .description('Restore origin files from sandbox apply backups')
165
+ .addHelpText('after', unapplyHelp)
147
166
  .action(unapplyCommand);
148
167
  }
149
168
  function rejectUnknownTopLevelCommand(args) {
@@ -162,55 +181,69 @@ program
162
181
  .description('Interactive CLI for managing git worktrees and configs')
163
182
  .version(getVersion())
164
183
  .allowExcessArguments(false)
184
+ .addHelpText('after', intentRouterHelp)
165
185
  .action(async () => {
166
- // Interactive Menu if no command is provided
167
- await welcome();
168
- await notifyIfUpdateAvailable();
186
+ const update = await checkForUpdate();
187
+ await welcome({ update });
169
188
  const isInSandbox = Boolean(await findSandboxRoot(process.cwd()));
170
- const realmChoices = [
171
- { name: `๐ŸŒฟ Grow New Realm ${chalk.dim('(create worktree)')}`, value: 'create-smart' },
172
- { name: `๐Ÿ”€ Traverse to Another Realm ${chalk.dim('(checkout existing branch in new worktree)')}`, value: 'worktree-checkout' },
173
- { name: `๐ŸŒณ Grow Many Realms ${chalk.dim('(create multiple worktrees)')}`, value: 'create-multi' },
174
- { name: `๐Ÿค Hand Off Current Work ${chalk.dim('(carry dirty work into a sandbox)')}`, value: 'handoff' },
175
- { name: `๐Ÿงช Forge Sandbox Realm ${chalk.dim('(create sandbox worktree)')}`, value: 'create-sandbox' },
176
- { name: `๐Ÿ—บ๏ธ Survey Realms ${chalk.dim('(list worktrees)')}`, value: 'list' },
177
- { name: `๐Ÿงญ Open Realm in Editor ${chalk.dim('(open worktree in editor)')}`, value: 'open' },
178
- { name: `๐Ÿช“ Fell a Realm ${chalk.dim('(delete worktree)')}`, value: 'delete' },
179
- { name: `๐Ÿš€ Bless Realm Setup ${chalk.dim('(bootstrap worktree)')}`, value: 'bootstrap' },
180
- { name: `๐Ÿงน Prune Withered Realms ${chalk.dim('(prune stale worktrees)')}`, value: 'prune' },
181
- { name: `๐Ÿš Cast a Command ${chalk.dim('(exec command in worktree)')}`, value: 'exec' },
182
- { name: `๐Ÿ“ Reveal Realm Path ${chalk.dim('(show worktree path)')}`, value: 'path' },
189
+ const dailyChoices = [
190
+ mainMenuChoice('create-smart', 'โ—†', 'Grow new realm', 'create a branch-backed worktree', 'growth'),
191
+ mainMenuChoice('worktree-checkout', 'โ‡„', 'Check out branch', 'open an existing ref in a new realm', 'travel'),
192
+ mainMenuChoice('handoff', 'โœฆ', 'Hand off current work', 'carry dirty work into a named sandbox', 'sandbox'),
193
+ mainMenuChoice('open', 'โ—‡', 'Open realm', 'jump into an existing worktree', 'travel'),
194
+ mainMenuChoice('list', 'โ—‹', 'Survey realms', 'scan active worktrees and PR state', 'tending'),
195
+ ];
196
+ const growthChoices = [
197
+ mainMenuChoice('create-multi', 'โœง', 'Grow many realms', 'create multiple branch worktrees', 'growth'),
198
+ mainMenuChoice('create-sandbox', 'โ–ณ', 'Forge sandbox', 'create a local experiment realm', 'sandbox'),
199
+ ];
200
+ const tendingChoices = [
201
+ mainMenuChoice('bootstrap', 'โ†ณ', 'Bootstrap realm', 'install dependencies and submodules', 'tending'),
202
+ mainMenuChoice('exec', 'โŒ', 'Cast command', 'run a command inside a worktree', 'tending'),
203
+ mainMenuChoice('path', 'โŒ–', 'Reveal path', 'print a worktree cd command', 'tending'),
204
+ mainMenuChoice('delete', 'ร—', 'Fell realm', 'delete managed worktrees', 'danger'),
205
+ mainMenuChoice('prune', 'โŒซ', 'Prune stale realms', 'remove stale worktree metadata', 'danger'),
183
206
  ];
184
207
  const sandboxChoices = [
185
- { name: `โœ… Graft Sandbox Changes ${chalk.dim('(apply sandbox changes)')}`, value: 'apply' },
186
- { name: `โ†ฉ๏ธ Undo Sandbox Graft ${chalk.dim('(unapply sandbox changes)')}`, value: 'unapply' },
208
+ mainMenuChoice('apply', 'โ—†', 'Graft sandbox changes', 'apply sandbox changes to origin', 'sandbox'),
209
+ mainMenuChoice('unapply', 'โ†ถ', 'Undo sandbox graft', 'restore origin from sandbox backups', 'sandbox'),
210
+ ];
211
+ const loreChoices = [
212
+ mainMenuChoice('bifrost', 'โœบ', 'Summon the Bifrost', 'open the long way around', 'lore'),
213
+ mainMenuChoice('thor', 'ฯŸ', 'Consult Thor', 'ask the thunder route', 'lore'),
214
+ mainMenuChoice('exit', 'ยท', 'Leave Yggdrasil', 'exit without changing anything', 'exit'),
187
215
  ];
188
216
  const choices = isInSandbox
189
217
  ? [
190
218
  ...sandboxChoices,
191
- new inquirer.Separator(),
192
- ...realmChoices,
193
- new inquirer.Separator(),
194
- { name: `๐ŸŒˆ Summon the Bifrost ${chalk.dim('(easter egg)')}`, value: 'bifrost' },
195
- { name: `โšก Consult Thor ${chalk.dim('(easter egg)')}`, value: 'thor' },
196
- { name: `๐Ÿšช Leave Yggdrasil ${chalk.dim('(exit)')}`, value: 'exit' },
219
+ mainMenuSeparator('Daily routes'),
220
+ ...dailyChoices,
221
+ mainMenuSeparator('Growth and experiments'),
222
+ ...growthChoices,
223
+ mainMenuSeparator('Tend realms'),
224
+ ...tendingChoices,
225
+ mainMenuSeparator('Lore'),
226
+ ...loreChoices,
197
227
  ]
198
228
  : [
199
- ...realmChoices,
200
- new inquirer.Separator(),
229
+ ...dailyChoices,
230
+ mainMenuSeparator('Growth and experiments'),
231
+ ...growthChoices,
232
+ mainMenuSeparator('Tend realms'),
233
+ ...tendingChoices,
234
+ mainMenuSeparator('Sandbox grafts'),
201
235
  ...sandboxChoices,
202
- new inquirer.Separator(),
203
- { name: `๐ŸŒˆ Summon the Bifrost ${chalk.dim('(easter egg)')}`, value: 'bifrost' },
204
- { name: `โšก Consult Thor ${chalk.dim('(easter egg)')}`, value: 'thor' },
205
- { name: `๐Ÿšช Leave Yggdrasil ${chalk.dim('(exit)')}`, value: 'exit' },
236
+ mainMenuSeparator('Lore'),
237
+ ...loreChoices,
206
238
  ];
239
+ console.log(renderMainMenuIntro({ isInSandbox }));
207
240
  const { action } = await inquirer.prompt([
208
241
  {
209
242
  type: 'list',
210
243
  name: 'action',
211
- message: 'What would you like to do?',
244
+ message: 'Which route should Yggtree follow?',
212
245
  loop: false,
213
- pageSize: 12,
246
+ pageSize: 14,
214
247
  choices,
215
248
  },
216
249
  ]);
@@ -270,6 +303,7 @@ program
270
303
  });
271
304
  // --- Worktree Commands ---
272
305
  const wt = program.command('wt').description('Manage git worktrees');
306
+ wt.addHelpText('after', intentRouterHelp);
273
307
  registerWorktreeCommands(program);
274
308
  registerWorktreeCommands(wt);
275
309
  program.addHelpCommand(true);
@@ -279,5 +313,21 @@ program.command('bifrost')
279
313
  program.command('thor')
280
314
  .description('Consult the God of Thunder (Easter Egg)')
281
315
  .action(thorCommand);
316
+ const config = program.command('config').description('Inspect or update global Yggtree settings');
317
+ config.command('get')
318
+ .description('Show resolved global settings')
319
+ .action(configGetCommand);
320
+ config.command('use <preset>')
321
+ .description('Use a global worktree path preset (default, yggtree, codex)')
322
+ .action(configUseCommand);
323
+ config.command('set-worktrees-root <path>')
324
+ .description('Set the global managed worktrees root')
325
+ .action(configSetWorktreesRootCommand);
326
+ config.command('set-worktree-layout <layout>')
327
+ .description('Set the managed worktree path layout (yggtree or codex)')
328
+ .action(configSetWorktreeLayoutCommand);
329
+ config.command('reset')
330
+ .description('Reset global settings to defaults')
331
+ .action(configResetCommand);
282
332
  rejectUnknownTopLevelCommand(argv);
283
333
  program.parse(argv);
@@ -0,0 +1,89 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+ import fs from 'fs-extra';
4
+ import { YGG_ROOT } from './paths.js';
5
+ const CONFIG_PATH = path.join(YGG_ROOT, 'config.json');
6
+ const DEFAULT_WORKTREE_LAYOUT = 'yggtree';
7
+ export function expandHome(value) {
8
+ if (value === '~')
9
+ return os.homedir();
10
+ if (value.startsWith('~/'))
11
+ return path.join(os.homedir(), value.slice(2));
12
+ return value;
13
+ }
14
+ export function formatHome(value) {
15
+ const home = os.homedir();
16
+ if (value === home)
17
+ return '~';
18
+ if (value.startsWith(`${home}${path.sep}`))
19
+ return `~/${path.relative(home, value)}`;
20
+ return value;
21
+ }
22
+ function normalizeRoot(value) {
23
+ return path.resolve(expandHome(value || YGG_ROOT));
24
+ }
25
+ export function normalizeWorktreesRootInput(value) {
26
+ return path.resolve(expandHome(value));
27
+ }
28
+ function normalizeLayout(value) {
29
+ return value || DEFAULT_WORKTREE_LAYOUT;
30
+ }
31
+ export async function readGlobalConfig() {
32
+ try {
33
+ if (await fs.pathExists(CONFIG_PATH)) {
34
+ const config = await fs.readJSON(CONFIG_PATH);
35
+ return {
36
+ worktreesRoot: typeof config.worktreesRoot === 'string' ? config.worktreesRoot : undefined,
37
+ worktreeLayout: config.worktreeLayout === 'codex' ? 'codex' : undefined,
38
+ };
39
+ }
40
+ }
41
+ catch {
42
+ return {};
43
+ }
44
+ return {};
45
+ }
46
+ export async function writeGlobalConfig(config) {
47
+ await fs.ensureDir(YGG_ROOT);
48
+ await fs.writeJSON(CONFIG_PATH, config, { spaces: 2 });
49
+ }
50
+ export async function getWorktreePathConfig() {
51
+ const config = await readGlobalConfig();
52
+ return {
53
+ root: normalizeRoot(config.worktreesRoot),
54
+ layout: normalizeLayout(config.worktreeLayout),
55
+ };
56
+ }
57
+ export async function getManagedWorktreesRoot() {
58
+ const config = await getWorktreePathConfig();
59
+ return config.root;
60
+ }
61
+ export function buildManagedWorktreePath(repoName, worktreeSlug, config) {
62
+ if (config.layout === 'codex') {
63
+ return path.join(config.root, worktreeSlug, repoName);
64
+ }
65
+ return path.join(config.root, repoName, worktreeSlug);
66
+ }
67
+ export function getPresetConfig(preset) {
68
+ const normalized = preset.trim().toLowerCase();
69
+ if (normalized === 'default' || normalized === 'yggtree') {
70
+ return {};
71
+ }
72
+ if (normalized === 'codex') {
73
+ return {
74
+ worktreesRoot: '~/.codex/worktrees',
75
+ worktreeLayout: 'codex',
76
+ };
77
+ }
78
+ return undefined;
79
+ }
80
+ export function formatGlobalConfig(config, resolved) {
81
+ return [
82
+ `worktreesRoot: ${formatHome(resolved.root)}`,
83
+ `worktreeLayout: ${resolved.layout}`,
84
+ `configFile: ${formatHome(CONFIG_PATH)}`,
85
+ config.worktreesRoot || config.worktreeLayout
86
+ ? 'preset: custom'
87
+ : 'preset: default',
88
+ ];
89
+ }
@@ -0,0 +1,93 @@
1
+ export const intentRouterHelp = `
2
+ Choose by intent:
3
+ New official task branch yggtree create feat/name --base main --source remote
4
+ Existing branch/interruption yggtree wc --ref branch-name
5
+ Continue dirty current work yggtree handoff --name task-name
6
+ Disposable local experiment yggtree create-sandbox
7
+ Copy sandbox files to origin yggtree apply # from inside sandbox, not a Git merge
8
+ Bulk official worktrees yggtree create-multi
9
+
10
+ More detail: run yggtree help <command>, for example yggtree help handoff.
11
+ `;
12
+ export const createHelp = `
13
+ Behavior:
14
+ Creates an official branch-backed worktree, publishes the branch to origin, and enters the shell by default.
15
+ Use for real task branches. For disposable experiments, use create-sandbox.
16
+
17
+ Examples:
18
+ yggtree create feat/new-flow --base main --source remote
19
+ yggtree create feat/background-task --base main --source remote --no-open --no-enter
20
+ `;
21
+ export const createMultiHelp = `
22
+ Behavior:
23
+ Bulk-creates official branch-backed worktrees.
24
+ This does not share create's open/enter/exec lifecycle. Use create for one task branch.
25
+
26
+ Example:
27
+ yggtree create-multi --base main --source remote
28
+ `;
29
+ export const checkoutHelp = `
30
+ Behavior:
31
+ Creates or reuses a worktree for an existing branch or ref without disturbing current work.
32
+ Use for interruptions and branch review. Use create for a new official task branch.
33
+ Enters the worktree shell by default; add --no-enter when automation should return.
34
+
35
+ Examples:
36
+ yggtree wc --ref hotfix/payment-timeout
37
+ yggtree wc --ref main --name fresh-main --no-open --no-enter
38
+ `;
39
+ export const openHelp = `
40
+ Behavior:
41
+ Opens an existing worktree in an editor, desktop app, or terminal target.
42
+ Returns by default after opening. Add --enter only when the shell should continue inside the worktree.
43
+
44
+ Examples:
45
+ yggtree open my-feature --tool cursor
46
+ yggtree open my-feature --tool codex-app
47
+ yggtree list --open
48
+ `;
49
+ export const createSandboxHelp = `
50
+ Behavior:
51
+ Creates a local-only disposable experiment from the current branch.
52
+ For continuing current dirty work, prefer handoff instead of teaching create-sandbox --carry.
53
+
54
+ Examples:
55
+ yggtree create-sandbox
56
+ yggtree create-sandbox --name ui-option-a --no-open
57
+
58
+ Related:
59
+ handoff carries current dirty work into a named sandbox.
60
+ apply copies changed sandbox files back to origin.
61
+ `;
62
+ export const handoffHelp = `
63
+ Behavior:
64
+ Carry staged, unstaged, and untracked work into a named sandbox so you can continue there.
65
+ The origin checkout is not cleaned; review or reset it separately if needed.
66
+
67
+ Example:
68
+ yggtree handoff --name continue-auth-refactor
69
+
70
+ Related:
71
+ create-sandbox starts a disposable local experiment.
72
+ apply copies changed sandbox files back to origin.
73
+ `;
74
+ export const applyHelp = `
75
+ Behavior:
76
+ Copies changed files from the current sandbox back to the origin checkout.
77
+ Stores backups in sandbox metadata before overwriting origin files.
78
+ Not a Git merge, rebase, patch, or cherry-pick. Review the origin diff afterward.
79
+
80
+ Example:
81
+ yggtree apply
82
+
83
+ Related:
84
+ unapply restores from the sandbox backup metadata while the sandbox still exists.
85
+ `;
86
+ export const unapplyHelp = `
87
+ Behavior:
88
+ Restores origin files from sandbox metadata created by apply.
89
+ Only works while the sandbox still exists.
90
+
91
+ Example:
92
+ yggtree unapply
93
+ `;
package/dist/lib/paths.js CHANGED
@@ -1,4 +1,3 @@
1
1
  import os from 'os';
2
2
  import path from 'path';
3
3
  export const YGG_ROOT = path.join(os.homedir(), '.yggtree');
4
- export const WORKTREES_ROOT = YGG_ROOT;
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import inquirer from 'inquirer';
3
3
  import { getRepoRoot } from './git.js';
4
+ import { getManagedWorktreesRoot } from './global-config.js';
4
5
  import { getValidRegisteredRepos } from './registry.js';
5
6
  import { log } from './ui.js';
6
7
  import { formatWorktreeDisplayPath } from './worktree.js';
@@ -11,6 +12,7 @@ export async function ensureRepoContext() {
11
12
  catch {
12
13
  const validRepos = await getValidRegisteredRepos();
13
14
  const repoEntries = Object.entries(validRepos);
15
+ const managedRoot = await getManagedWorktreesRoot();
14
16
  if (repoEntries.length === 0) {
15
17
  log.error('Not inside a git repository and no registered realms found.');
16
18
  log.dim('Run `yggtree` inside an existing git project first to register it.');
@@ -34,7 +36,7 @@ export async function ensureRepoContext() {
34
36
  name: 'selectedRepoPath',
35
37
  message: 'Select a realm:',
36
38
  choices: repoEntries.map(([name, repoPath]) => ({
37
- name: `${chalk.bold.yellow(name)} ${chalk.dim(formatWorktreeDisplayPath(repoPath))}`,
39
+ name: `${chalk.bold.yellow(name)} ${chalk.dim(formatWorktreeDisplayPath(repoPath, managedRoot))}`,
38
40
  value: repoPath,
39
41
  })),
40
42
  pageSize: 10,
package/dist/lib/ui.js CHANGED
@@ -6,15 +6,80 @@ import path from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { getVersion } from './version.js';
8
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
- // --- Personality & Branding ---
10
- export const welcome = async () => {
11
- console.log('');
9
+ const yggAccent = chalk.hex('#DEADED');
10
+ const yggLeaf = chalk.hex('#9AD68B');
11
+ const yggSky = chalk.hex('#7AC7D8');
12
+ const yggBark = chalk.hex('#C99A5B');
13
+ const yggRoot = chalk.hex('#B48AE8');
14
+ const yggWarn = chalk.hex('#E4B85E');
15
+ const yggDanger = chalk.hex('#E06C75');
16
+ const yggMuted = chalk.hex('#8D8D8D');
17
+ export function renderWelcome(options = {}) {
18
+ const rule = yggAccent('โ”€'.repeat(54));
12
19
  const title = figlet.textSync('Yggdrasil', { font: 'Standard' });
13
- console.log(gradient.mind.multiline(title));
14
20
  const version = getVersion();
15
- console.log(chalk.dim(` v${version} โ€ข The World Tree Worktree Assistant`));
16
- console.log('');
21
+ const updateLines = renderUpdateNotice(options.update);
22
+ return [
23
+ '',
24
+ rule,
25
+ gradient.mind.multiline(title),
26
+ chalk.bold(yggAccent(' Yggdrasil')),
27
+ chalk.dim(` v${version} โ€ข The World Tree Worktree Assistant`),
28
+ ...updateLines,
29
+ rule,
30
+ '',
31
+ ].join('\n');
32
+ }
33
+ export const welcome = async (options = {}) => {
34
+ console.log(renderWelcome(options));
17
35
  };
36
+ export function renderMainMenuIntro(options) {
37
+ const detail = options.isInSandbox
38
+ ? 'Sandbox tools appear first because this realm can apply or undo grafted changes.'
39
+ : 'Create, enter, inspect, or tend your worktrees.';
40
+ return [
41
+ 'Choose the next realm action.',
42
+ yggMuted('Daily routes first. Maintenance and lore wait below.'),
43
+ '',
44
+ `${yggBark('โ”Œ')} ${badge('route 01')} ${chalk.bold('Realm')}`,
45
+ `${yggBark('โ”‚')} ${yggMuted(detail)}`,
46
+ ].join('\n');
47
+ }
48
+ export function formatMainMenuChoice(options) {
49
+ const tone = menuTone(options.tone);
50
+ const label = options.label.padEnd(24);
51
+ return `${tone(options.glyph)} ${chalk.bold(label)} ${yggMuted(options.detail)}`;
52
+ }
53
+ function renderUpdateNotice(update) {
54
+ if (!update?.updateAvailable)
55
+ return [];
56
+ return [
57
+ '',
58
+ `${yggWarn('Update available')} ${yggMuted(`yggtree ${update.currentVersion} -> ${update.latestVersion}`)}`,
59
+ yggMuted('Run: npm install -g yggtree'),
60
+ ];
61
+ }
62
+ function badge(value) {
63
+ return yggRoot(` ${value} `);
64
+ }
65
+ function menuTone(tone) {
66
+ switch (tone) {
67
+ case 'growth':
68
+ return yggLeaf;
69
+ case 'travel':
70
+ return yggSky;
71
+ case 'sandbox':
72
+ return yggRoot;
73
+ case 'tending':
74
+ return yggBark;
75
+ case 'danger':
76
+ return yggDanger;
77
+ case 'lore':
78
+ return yggAccent;
79
+ case 'exit':
80
+ return yggMuted;
81
+ }
82
+ }
18
83
  // --- Logger ---
19
84
  export const log = {
20
85
  info: (msg) => console.log(chalk.blue('โ„น'), msg),
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
- import { WORKTREES_ROOT } from './paths.js';
4
+ import { YGG_ROOT } from './paths.js';
5
5
  import { getSandboxMetaPath } from './sandbox.js';
6
6
  export const WORKTREE_TYPE_ORDER = {
7
7
  MAIN: 0,
@@ -12,19 +12,23 @@ export const WORKTREE_TYPE_ORDER = {
12
12
  export function getWorktreeBranchName(worktree) {
13
13
  return worktree.branch || worktree.HEAD || 'detached';
14
14
  }
15
- export function isManagedWorktreePath(worktreePath) {
16
- return worktreePath.startsWith(WORKTREES_ROOT);
15
+ function isPathInsideRoot(worktreePath, managedRoot) {
16
+ const relative = path.relative(managedRoot, worktreePath);
17
+ return relative === '' || Boolean(relative && !relative.startsWith('..') && !path.isAbsolute(relative));
17
18
  }
18
- export function formatWorktreeDisplayPath(worktreePath) {
19
- if (isManagedWorktreePath(worktreePath)) {
20
- return path.relative(WORKTREES_ROOT, worktreePath);
19
+ export function isManagedWorktreePath(worktreePath, managedRoot = YGG_ROOT) {
20
+ return isPathInsideRoot(path.resolve(worktreePath), path.resolve(managedRoot));
21
+ }
22
+ export function formatWorktreeDisplayPath(worktreePath, managedRoot = YGG_ROOT) {
23
+ if (isManagedWorktreePath(worktreePath, managedRoot)) {
24
+ return path.relative(managedRoot, worktreePath);
21
25
  }
22
26
  return worktreePath.replace(process.env.HOME || '', '~');
23
27
  }
24
- export function findWorktreeByName(worktrees, worktreeName) {
28
+ export function findWorktreeByName(worktrees, worktreeName, managedRoot = YGG_ROOT) {
25
29
  return worktrees.find(worktree => {
26
30
  const branchName = getWorktreeBranchName(worktree);
27
- const relativePath = path.relative(WORKTREES_ROOT, worktree.path);
31
+ const relativePath = path.relative(managedRoot, worktree.path);
28
32
  const basename = path.basename(worktree.path);
29
33
  return branchName === worktreeName ||
30
34
  relativePath === worktreeName ||
@@ -32,8 +36,8 @@ export function findWorktreeByName(worktrees, worktreeName) {
32
36
  basename === worktreeName;
33
37
  });
34
38
  }
35
- export async function detectWorktreeType(worktree, mainWorktreePath) {
36
- const isManaged = isManagedWorktreePath(worktree.path);
39
+ export async function detectWorktreeType(worktree, mainWorktreePath, managedRoot = YGG_ROOT) {
40
+ const isManaged = isManagedWorktreePath(worktree.path, managedRoot);
37
41
  if (!isManaged) {
38
42
  return worktree.path === mainWorktreePath ? 'MAIN' : 'LINKED';
39
43
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yggtree",
3
- "version": "1.4.4",
3
+ "version": "1.4.5",
4
4
  "packageManager": "pnpm@11.0.9",
5
5
  "description": "Interactive CLI for managing git worktrees and configs",
6
6
  "main": "dist/index.js",